Initial commit: Family Planner application

Complete family planning application with:
- React frontend with TypeScript
- Node.js/Express backend with TypeScript
- Python ingestion service for document processing
- Planning ingestion service with LLM integration
- Shared UI components and type definitions
- OAuth integration for calendar synchronization
- Comprehensive documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
philippe
2025-10-14 10:43:33 +02:00
commit fdd72c1135
239 changed files with 44160 additions and 0 deletions

17
frontend/src/App.js Normal file
View File

@@ -0,0 +1,17 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { Route, Routes } from "react-router-dom";
import { Layout } from "./components/Layout";
import { DashboardScreen } from "./screens/DashboardScreen";
import { SettingsScreen } from "./screens/SettingsScreen";
import { AddPersonScreen } from "./screens/AddPersonScreen";
import { ChildPlanningScreen } from "./screens/ChildPlanningScreen";
import { MonthlyCalendarScreen } from "./screens/MonthlyCalendarScreen";
import { ParentsScreen } from "./screens/ParentsScreen";
import { PersonPlanningScreen } from "./screens/PersonPlanningScreen";
import { CalendarOAuthCallbackScreen } from "./screens/CalendarOAuthCallbackScreen";
import { ChildDetailScreen } from "./screens/ChildDetailScreen";
import { AdultDetailScreen } from "./screens/AdultDetailScreen";
const App = () => {
return (_jsx(Layout, { children: _jsxs(Routes, { children: [_jsx(Route, { path: "/", element: _jsx(DashboardScreen, {}) }), _jsx(Route, { path: "/profiles", element: _jsx(ParentsScreen, {}) }), _jsx(Route, { path: "/profiles/new", element: _jsx(AddPersonScreen, {}) }), _jsx(Route, { path: "/profiles/child/:childId", element: _jsx(ChildDetailScreen, {}) }), _jsx(Route, { path: "/profiles/:profileType/:profileId", element: _jsx(AdultDetailScreen, {}) }), _jsx(Route, { path: "/profiles/:profileType/:profileId/planning", element: _jsx(PersonPlanningScreen, {}) }), _jsx(Route, { path: "/children/:childId/planning", element: _jsx(ChildPlanningScreen, {}) }), _jsx(Route, { path: "/calendar/month", element: _jsx(MonthlyCalendarScreen, {}) }), _jsx(Route, { path: "/calendar/oauth/callback", element: _jsx(CalendarOAuthCallbackScreen, {}) }), _jsx(Route, { path: "/settings", element: _jsx(SettingsScreen, {}) })] }) }));
};
export default App;

42
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,42 @@
import { Route, Routes } from "react-router-dom";
import { Layout } from "./components/Layout";
import { DashboardScreen } from "./screens/DashboardScreen";
import { SettingsScreen } from "./screens/SettingsScreen";
import { AddPersonScreen } from "./screens/AddPersonScreen";
import { ChildPlanningScreen } from "./screens/ChildPlanningScreen";
import { MonthlyCalendarScreen } from "./screens/MonthlyCalendarScreen";
import { ParentsScreen } from "./screens/ParentsScreen";
import { PersonPlanningScreen } from "./screens/PersonPlanningScreen";
import { CalendarOAuthCallbackScreen } from "./screens/CalendarOAuthCallbackScreen";
import { ChildDetailScreen } from "./screens/ChildDetailScreen";
import { ParentDetailScreen } from "./screens/ParentDetailScreen";
import { GrandParentDetailScreen } from "./screens/GrandParentDetailScreen";
import { AdultDetailScreen } from "./screens/AdultDetailScreen";
const App = () => {
return (
<Layout>
<Routes>
<Route path="/" element={<DashboardScreen />} />
<Route path="/profiles" element={<ParentsScreen />} />
<Route path="/profiles/new" element={<AddPersonScreen />} />
<Route path="/profiles/child/:childId" element={<ChildDetailScreen />} />
<Route path="/profiles/parent/:parentId" element={<ParentDetailScreen />} />
<Route path="/profiles/grandparent/:grandParentId" element={<GrandParentDetailScreen />} />
<Route path="/profiles/:profileType/:profileId" element={<AdultDetailScreen />} />
<Route path="/profiles/:profileType/:profileId/planning" element={<PersonPlanningScreen />} />
<Route path="/children/:childId/planning" element={<ChildPlanningScreen />} />
<Route path="/calendar/month" element={<MonthlyCalendarScreen />} />
<Route path="/calendar/oauth/callback" element={<CalendarOAuthCallbackScreen />} />
<Route path="/settings" element={<SettingsScreen />} />
</Routes>
</Layout>
);
};
export default App;

View File

@@ -0,0 +1,77 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import styled from "styled-components";
import { useMemo } from "react";
import { ProfileActionBar } from "./ProfileActionBar";
const Card = styled.article `
padding: 18px 20px;
border-radius: 16px;
background: rgba(29, 36, 66, 0.92);
border: 1px solid rgba(126, 136, 180, 0.22);
display: flex;
gap: 18px;
align-items: center;
transition: transform 0.2s ease, box-shadow 0.2s ease;
cursor: ${({ $clickable }) => ($clickable ? "pointer" : "default")};
&:hover {
transform: translateY(-2px);
box-shadow: 0 12px 32px ${({ $color }) => `${$color}33`};
}
`;
const Avatar = styled.div `
width: 56px;
height: 56px;
border-radius: 18px;
background: rgba(9, 13, 28, 0.8);
border: 2px solid ${({ $color }) => $color};
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
color: #f4f5ff;
font-size: 1.1rem;
overflow: hidden;
box-shadow: 0 0 12px ${({ $color }) => `${$color}55`};
`;
const AvatarImage = styled.img `
width: 100%;
height: 100%;
object-fit: cover;
`;
const Content = styled.div `
display: flex;
flex-direction: column;
gap: 6px;
`;
const Name = styled.span `
font-size: 1.1rem;
font-weight: 600;
`;
const Email = styled.span `
color: var(--text-muted);
font-size: 0.9rem;
`;
const Notes = styled.span `
color: var(--text-muted);
font-size: 0.95rem;
`;
export const ChildCard = ({ child, onDelete, onEdit, onViewProfile, onViewPlanning, onOpenPlanningCenter, importing, connectionsCount }) => {
const initials = useMemo(() => child.fullName
.split(" ")
.map((part) => part.charAt(0))
.join("")
.slice(0, 2)
.toUpperCase(), [child.fullName]);
const handleCardClick = (e) => {
// Ne pas déclencher si on clique sur un bouton dans la ProfileActionBar
const target = e.target;
if (target.closest("button")) {
return;
}
// Toute la carte est cliquable pour accéder au profil
if (onViewProfile) {
onViewProfile(child.id);
}
};
return (_jsxs(Card, { "$color": child.colorHex, "$clickable": !!onViewProfile, onClick: handleCardClick, children: [_jsx(Avatar, { "$color": child.colorHex, children: child.avatar ? (_jsx(AvatarImage, { src: child.avatar.url, alt: child.avatar.name ?? `Portrait de ${child.fullName}` })) : (initials) }), _jsxs(Content, { children: [_jsx(Name, { children: child.fullName }), child.email && _jsx(Email, { children: child.email }), child.notes && _jsx(Notes, { children: child.notes })] }), _jsx(ProfileActionBar, { onViewProfile: onViewProfile ? () => onViewProfile(child.id) : undefined })] }));
};

View File

@@ -0,0 +1,133 @@
import styled from "styled-components";
import { useMemo } from "react";
import { ChildProfile } from "@family-planner/types";
import { ProfileActionBar } from "./ProfileActionBar";
type ChildCardProps = {
child: ChildProfile;
onDelete?: (id: string) => void;
onEdit?: (id: string) => void;
onViewProfile?: (id: string) => void;
onViewPlanning?: (id: string) => void;
onOpenPlanningCenter?: (child: ChildProfile) => void;
importing?: boolean;
connectionsCount?: number;
};
const Card = styled.article<{ $color: string; $clickable?: boolean }>`
padding: 18px 20px;
border-radius: 16px;
background: rgba(29, 36, 66, 0.92);
border: 1px solid rgba(126, 136, 180, 0.22);
display: flex;
gap: 18px;
align-items: center;
transition: transform 0.2s ease, box-shadow 0.2s ease;
cursor: ${({ $clickable }) => ($clickable ? "pointer" : "default")};
&:hover {
transform: translateY(-2px);
box-shadow: 0 12px 32px ${({ $color }) => `${$color}33`};
}
`;
const Avatar = styled.div<{ $color: string }>`
width: 56px;
height: 56px;
border-radius: 18px;
background: rgba(9, 13, 28, 0.8);
border: 2px solid ${({ $color }) => $color};
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
color: #f4f5ff;
font-size: 1.1rem;
overflow: hidden;
box-shadow: 0 0 12px ${({ $color }) => `${$color}55`};
`;
const AvatarImage = styled.img`
width: 100%;
height: 100%;
object-fit: cover;
`;
const Content = styled.div`
display: flex;
flex-direction: column;
gap: 6px;
`;
const Name = styled.span`
font-size: 1.1rem;
font-weight: 600;
`;
const Email = styled.span`
color: var(--text-muted);
font-size: 0.9rem;
`;
const Notes = styled.span`
color: var(--text-muted);
font-size: 0.95rem;
`;
export const ChildCard = ({
child,
onDelete,
onEdit,
onViewProfile,
onViewPlanning,
onOpenPlanningCenter,
importing,
connectionsCount
}: ChildCardProps) => {
const initials = useMemo(
() =>
child.fullName
.split(" ")
.map((part: string) => part.charAt(0))
.join("")
.slice(0, 2)
.toUpperCase(),
[child.fullName]
);
const handleCardClick = (e: React.MouseEvent) => {
// Ne pas déclencher si on clique sur un bouton dans la ProfileActionBar
const target = e.target as HTMLElement;
if (target.closest("button")) {
return;
}
// Toute la carte est cliquable pour accéder au profil
if (onViewProfile) {
onViewProfile(child.id);
}
};
return (
<Card
$color={child.colorHex}
$clickable={!!onViewProfile}
onClick={handleCardClick}
>
<Avatar $color={child.colorHex}>
{child.avatar ? (
<AvatarImage src={child.avatar.url} alt={child.avatar.name ?? `Portrait de ${child.fullName}`} />
) : (
initials
)}
</Avatar>
<Content>
<Name>{child.fullName}</Name>
{child.email && <Email>{child.email}</Email>}
{child.notes && <Notes>{child.notes}</Notes>}
</Content>
<ProfileActionBar
onViewProfile={onViewProfile ? () => onViewProfile(child.id) : undefined}
/>
</Card>
);
};

View File

@@ -0,0 +1,394 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useEffect, useMemo, useRef, useState } from "react";
import styled from "styled-components";
import { useChildren } from "../state/ChildrenContext";
import { uploadAvatar, listAvatars } from "../services/api-client";
const Panel = styled.aside `
flex: 1;
padding: 24px;
border-radius: 18px;
background: rgba(29, 36, 66, 0.92);
border: 1px solid rgba(126, 136, 180, 0.22);
display: flex;
flex-direction: column;
gap: 20px;
`;
const Title = styled.h2 `
margin: 0;
`;
const Description = styled.p `
margin: 0;
color: var(--text-muted);
`;
const Form = styled.form `
display: flex;
flex-direction: column;
gap: 18px;
`;
const Label = styled.label `
display: flex;
flex-direction: column;
gap: 8px;
font-weight: 600;
`;
const Row = styled.div `
display: flex;
gap: 12px;
flex-wrap: wrap;
`;
const BaseInput = styled.input `
padding: 12px 14px;
border-radius: 12px;
border: 1px solid rgba(126, 136, 180, 0.28);
background: rgba(16, 22, 52, 0.9);
color: #ffffff;
`;
const TextArea = styled.textarea `
padding: 12px 14px;
border-radius: 12px;
border: 1px solid rgba(126, 136, 180, 0.28);
min-height: 96px;
background: rgba(16, 22, 52, 0.9);
color: #ffffff;
`;
const SubmitButton = styled.button `
padding: 12px 16px;
border-radius: 12px;
border: none;
background: ${({ $loading }) => $loading ? "rgba(85, 98, 255, 0.25)" : "linear-gradient(135deg, #5562ff, #7d6cff)"};
color: #ffffff;
font-weight: 600;
cursor: ${({ $loading }) => ($loading ? "progress" : "pointer")};
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: opacity 0.2s ease;
opacity: ${({ $loading }) => ($loading ? 0.7 : 1)};
`;
const SecondaryButton = styled.button `
padding: 12px 16px;
border-radius: 12px;
border: 1px solid rgba(126, 136, 180, 0.4);
background: rgba(16, 22, 52, 0.9);
color: #d9dcff;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s ease;
&:hover {
transform: translateY(-1px);
}
`;
const AvatarSection = styled.section `
display: flex;
flex-direction: column;
gap: 16px;
`;
const AvatarHeader = styled.div `
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
`;
const AvatarPreview = styled.div `
display: flex;
align-items: center;
gap: 14px;
padding: 12px 14px;
border-radius: 14px;
background: rgba(12, 18, 42, 0.8);
border: 1px solid rgba(126, 136, 180, 0.24);
`;
const AvatarImage = styled.img `
width: 64px;
height: 64px;
border-radius: 18px;
object-fit: cover;
border: 2px solid rgba(126, 136, 180, 0.35);
`;
const AvatarFallback = styled.div `
width: 64px;
height: 64px;
border-radius: 18px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 1.4rem;
background: ${({ $color }) => $color};
color: #040411;
`;
const AvatarInfo = styled.div `
display: flex;
flex-direction: column;
gap: 4px;
color: var(--text-muted);
`;
const AvatarPicker = styled.div `
border-radius: 16px;
background: rgba(10, 14, 34, 0.6);
border: 1px solid rgba(148, 156, 210, 0.24);
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
`;
const GalleryGrid = styled.div `
display: grid;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 12px;
`;
const GalleryItem = styled.button `
border: ${({ $selected }) => ($selected ? "2px solid #7d6cff" : "1px solid rgba(126,136,180,0.3)")};
border-radius: 14px;
background: rgba(16, 22, 52, 0.85);
padding: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.2s ease, border 0.2s ease;
&:hover {
transform: translateY(-2px);
}
`;
const GalleryThumbnail = styled.img `
width: 100%;
height: 100%;
border-radius: 10px;
object-fit: cover;
`;
const Helper = styled.span `
font-size: 0.85rem;
color: var(--text-muted);
`;
const ErrorText = styled.span `
font-size: 0.85rem;
color: #ff7b8a;
`;
const StatusMessage = styled.div `
padding: 12px;
border-radius: 12px;
background: rgba(12, 18, 42, 0.7);
border: 1px solid rgba(126, 136, 180, 0.24);
color: var(--text-muted);
font-size: 0.9rem;
`;
const DEFAULT_COLOR = "#5562ff";
export const ChildProfilePanel = ({ mode = "create", child, onCancel }) => {
const isEdit = mode === "edit" && !!child;
const { createChild, updateChild } = useChildren();
const [fullName, setFullName] = useState(child?.fullName ?? "");
const [colorHex, setColorHex] = useState(child?.colorHex ?? DEFAULT_COLOR);
const [email, setEmail] = useState(child?.email ?? "");
const [notes, setNotes] = useState(child?.notes ?? "");
const [avatarSelection, setAvatarSelection] = useState(null);
const [removeExistingAvatar, setRemoveExistingAvatar] = useState(false);
const [avatarPickerOpen, setAvatarPickerOpen] = useState(false);
const [gallery, setGallery] = useState([]);
const [galleryLoading, setGalleryLoading] = useState(false);
const [galleryError, setGalleryError] = useState(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState(null);
const fileInputRef = useRef(null);
useEffect(() => {
if (avatarSelection?.source === "upload") {
return () => {
URL.revokeObjectURL(avatarSelection.previewUrl);
};
}
return undefined;
}, [avatarSelection]);
useEffect(() => {
if (!gallery.length) {
setGalleryLoading(true);
listAvatars()
.then((items) => {
setGallery(items);
setGalleryError(null);
})
.catch(() => {
setGalleryError("Impossible de charger la galerie locale.");
})
.finally(() => setGalleryLoading(false));
}
}, [gallery.length]);
useEffect(() => {
if (isEdit && child) {
setFullName(child.fullName);
setColorHex(child.colorHex);
setEmail(child.email ?? "");
setNotes(child.notes ?? "");
setAvatarSelection(null);
setRemoveExistingAvatar(false);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
setError(null);
}
else if (!isEdit) {
setFullName("");
setColorHex(DEFAULT_COLOR);
setEmail("");
setNotes("");
setAvatarSelection(null);
setRemoveExistingAvatar(false);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
setError(null);
}
}, [isEdit, child]);
const initials = useMemo(() => {
return fullName
.split(" ")
.filter(Boolean)
.map((part) => part.charAt(0))
.join("")
.slice(0, 2)
.toUpperCase();
}, [fullName]);
const currentAvatarUrl = useMemo(() => {
if (avatarSelection) {
return avatarSelection.source === "upload" ? avatarSelection.previewUrl : avatarSelection.url;
}
if (removeExistingAvatar) {
return null;
}
return child?.avatar?.url ?? null;
}, [avatarSelection, child?.avatar, removeExistingAvatar]);
const currentAvatarLabel = useMemo(() => {
if (avatarSelection) {
return avatarSelection.source === "upload"
? avatarSelection.name
: avatarSelection.name ?? "Avatar local";
}
if (removeExistingAvatar) {
return "Aucun avatar selectionne";
}
return child?.avatar?.name ?? "Aucun avatar selectionne";
}, [avatarSelection, child?.avatar, removeExistingAvatar]);
const handleFileChange = (event) => {
const file = event.target.files?.[0];
if (!file) {
return;
}
if (!file.type.startsWith("image/")) {
setError("Le fichier doit etre une image (png, jpg, svg...).");
return;
}
setAvatarSelection({
source: "upload",
file,
previewUrl: URL.createObjectURL(file),
name: file.name
});
setRemoveExistingAvatar(false);
setError(null);
};
const handleSelectGallery = (item) => {
setAvatarSelection({
source: "gallery",
url: item.url,
name: item.filename
});
setRemoveExistingAvatar(false);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const handleClearAvatar = () => {
if (avatarSelection?.source === "upload") {
URL.revokeObjectURL(avatarSelection.previewUrl);
}
setAvatarSelection(null);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
setRemoveExistingAvatar(isEdit);
};
const handleSubmit = async (event) => {
event.preventDefault();
if (isSubmitting) {
return;
}
if (!fullName.trim()) {
setError("Merci de saisir le nom complet de l enfant.");
return;
}
const payload = {
fullName: fullName.trim(),
colorHex,
email: email.trim() ? email.trim() : undefined,
notes: notes.trim() ? notes.trim() : undefined
};
setIsSubmitting(true);
setError(null);
try {
let avatarPayload = undefined;
if (avatarSelection?.source === "upload") {
const uploaded = await uploadAvatar(avatarSelection.file);
avatarPayload = {
kind: "custom",
url: uploaded.url,
name: avatarSelection.name
};
URL.revokeObjectURL(avatarSelection.previewUrl);
setAvatarSelection({
source: "gallery",
url: uploaded.url,
name: avatarSelection.name
});
}
else if (avatarSelection?.source === "gallery") {
avatarPayload = {
kind: "custom",
url: avatarSelection.url,
name: avatarSelection.name
};
}
else if (isEdit && child?.avatar && !removeExistingAvatar) {
avatarPayload = child.avatar;
}
else if (removeExistingAvatar) {
avatarPayload = null;
}
if (avatarPayload !== undefined) {
payload.avatar = avatarPayload ?? undefined;
}
if (isEdit && child) {
await updateChild(child.id, payload);
onCancel?.();
}
else {
await createChild(payload);
setFullName("");
setColorHex(DEFAULT_COLOR);
setEmail("");
setNotes("");
setAvatarSelection(null);
setRemoveExistingAvatar(false);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
}
catch (err) {
setError("Impossible d enregistrer pour le moment. Merci de reessayer.");
}
finally {
setIsSubmitting(false);
}
};
return (_jsxs(Panel, { children: [_jsx(Title, { children: isEdit ? "Modifier l enfant" : "Ajouter un enfant" }), _jsx(Description, { children: isEdit
? "Ajuste le profil, la couleur ou l avatar. Les modifications sont visibles partout."
: "Cree rapidement un nouveau profil en renseignant email et avatar pour automatiser les agendas." }), _jsxs(Form, { onSubmit: handleSubmit, children: [_jsxs(Label, { children: ["Prenom et nom", _jsx(BaseInput, { type: "text", placeholder: "Ex: Alice Durand", value: fullName, onChange: (event) => setFullName(event.target.value) })] }), _jsxs(Row, { children: [_jsxs(Label, { style: { flex: "1 1 180px" }, children: ["Adresse email", _jsx(BaseInput, { type: "email", placeholder: "prenom@exemple.com", value: email, onChange: (event) => setEmail(event.target.value) })] }), _jsxs(Label, { style: { width: "120px" }, children: ["Couleur", _jsx(BaseInput, { type: "color", value: colorHex, onChange: (event) => setColorHex(event.target.value) })] })] }), _jsxs(Label, { children: ["Notes", _jsx(TextArea, { placeholder: "Infos importantes, allergies...", value: notes, onChange: (event) => setNotes(event.target.value) })] }), _jsxs(AvatarSection, { children: [_jsxs(AvatarHeader, { children: [_jsx("strong", { children: "Avatar" }), _jsxs(Row, { children: [_jsx(SecondaryButton, { type: "button", onClick: () => setAvatarPickerOpen((open) => !open), children: avatarPickerOpen ? "Fermer" : "Choisir un avatar" }), (avatarSelection || (isEdit && child?.avatar && !removeExistingAvatar)) && (_jsx(SecondaryButton, { type: "button", onClick: handleClearAvatar, children: "Retirer l avatar" }))] })] }), _jsxs(AvatarPreview, { children: [currentAvatarUrl ? (_jsx(AvatarImage, { src: currentAvatarUrl, alt: currentAvatarLabel ?? "Avatar" })) : (_jsx(AvatarFallback, { "$color": colorHex, children: initials || "?" })), _jsxs(AvatarInfo, { children: [_jsx("span", { children: currentAvatarLabel }), _jsx(Helper, { children: "Les avatars importes sont stockes dans le dossier `backend/public/avatars/`." })] })] }), avatarPickerOpen ? (_jsxs(AvatarPicker, { children: [_jsxs("div", { children: [_jsx("strong", { children: "Importer un nouvel avatar" }), _jsx(Label, { children: _jsx(BaseInput, { ref: fileInputRef, type: "file", accept: "image/*", onChange: handleFileChange }) }), _jsx(Helper, { children: "Formats acceptes: png, jpg, svg. Taille conseillee 512x512. Les images importees sont stockees localement." })] }), _jsxs("div", { children: [_jsx("strong", { children: "Galerie locale" }), galleryLoading ? (_jsx(StatusMessage, { children: "Chargement de la galerie..." })) : galleryError ? (_jsx(StatusMessage, { children: galleryError })) : gallery.length === 0 ? (_jsx(StatusMessage, { children: "Aucune image dans `backend/public/avatars/`. Ajoute des fichiers pour les proposer ici." })) : (_jsx(GalleryGrid, { children: gallery.map((item) => (_jsx(GalleryItem, { "$selected": avatarSelection?.source === "gallery" && avatarSelection.url === item.url, type: "button", onClick: () => handleSelectGallery(item), children: _jsx(GalleryThumbnail, { src: item.url, alt: item.filename }) }, item.filename))) }))] })] })) : null] }), error ? _jsx(ErrorText, { children: error }) : null, _jsxs(Row, { children: [_jsx(SubmitButton, { type: "submit", disabled: isSubmitting, "$loading": isSubmitting, children: isSubmitting
? "Enregistrement..."
: isEdit
? "Mettre a jour le profil"
: "Enregistrer le profil" }), isEdit ? (_jsx(SecondaryButton, { type: "button", onClick: () => {
onCancel?.();
}, children: "Annuler" })) : null] })] })] }));
};

View File

@@ -0,0 +1,604 @@
import {
ChangeEvent,
FormEvent,
useEffect,
useMemo,
useRef,
useState
} from "react";
import styled from "styled-components";
import { ChildProfile } from "@family-planner/types";
import {
CreateChildPayload,
UpdateChildPayload,
useChildren
} from "../state/ChildrenContext";
import { uploadAvatar, listAvatars } from "../services/api-client";
type ChildProfilePanelProps = {
mode?: "create" | "edit";
child?: ChildProfile | null;
onCancel?: () => void;
};
type AvatarSelection =
| { source: "upload"; file: File; previewUrl: string; name: string }
| { source: "gallery"; url: string; name?: string };
type GalleryAvatar = {
filename: string;
url: string;
};
const Panel = styled.aside`
flex: 1;
padding: 24px;
border-radius: 18px;
background: rgba(29, 36, 66, 0.92);
border: 1px solid rgba(126, 136, 180, 0.22);
display: flex;
flex-direction: column;
gap: 20px;
`;
const Title = styled.h2`
margin: 0;
`;
const Description = styled.p`
margin: 0;
color: var(--text-muted);
`;
const Form = styled.form`
display: flex;
flex-direction: column;
gap: 18px;
`;
const Label = styled.label`
display: flex;
flex-direction: column;
gap: 8px;
font-weight: 600;
`;
const Row = styled.div`
display: flex;
gap: 12px;
flex-wrap: wrap;
`;
const BaseInput = styled.input`
padding: 12px 14px;
border-radius: 12px;
border: 1px solid rgba(126, 136, 180, 0.28);
background: rgba(16, 22, 52, 0.9);
color: #ffffff;
`;
const TextArea = styled.textarea`
padding: 12px 14px;
border-radius: 12px;
border: 1px solid rgba(126, 136, 180, 0.28);
min-height: 96px;
background: rgba(16, 22, 52, 0.9);
color: #ffffff;
`;
const SubmitButton = styled.button<{ $loading?: boolean }>`
padding: 12px 16px;
border-radius: 12px;
border: none;
background: ${({ $loading }) =>
$loading ? "rgba(85, 98, 255, 0.25)" : "linear-gradient(135deg, #5562ff, #7d6cff)"};
color: #ffffff;
font-weight: 600;
cursor: ${({ $loading }) => ($loading ? "progress" : "pointer")};
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: opacity 0.2s ease;
opacity: ${({ $loading }) => ($loading ? 0.7 : 1)};
`;
const SecondaryButton = styled.button`
padding: 12px 16px;
border-radius: 12px;
border: 1px solid rgba(126, 136, 180, 0.4);
background: rgba(16, 22, 52, 0.9);
color: #d9dcff;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s ease;
&:hover {
transform: translateY(-1px);
}
`;
const AvatarSection = styled.section`
display: flex;
flex-direction: column;
gap: 16px;
`;
const AvatarHeader = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
`;
const AvatarPreview = styled.div`
display: flex;
align-items: center;
gap: 14px;
padding: 12px 14px;
border-radius: 14px;
background: rgba(12, 18, 42, 0.8);
border: 1px solid rgba(126, 136, 180, 0.24);
`;
const AvatarImage = styled.img`
width: 64px;
height: 64px;
border-radius: 18px;
object-fit: cover;
border: 2px solid rgba(126, 136, 180, 0.35);
`;
const AvatarFallback = styled.div<{ $color: string }>`
width: 64px;
height: 64px;
border-radius: 18px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 1.4rem;
background: ${({ $color }) => $color};
color: #040411;
`;
const AvatarInfo = styled.div`
display: flex;
flex-direction: column;
gap: 4px;
color: var(--text-muted);
`;
const AvatarPicker = styled.div`
border-radius: 16px;
background: rgba(10, 14, 34, 0.6);
border: 1px solid rgba(148, 156, 210, 0.24);
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
`;
const GalleryGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 12px;
`;
const GalleryItem = styled.button<{ $selected?: boolean }>`
border: ${({ $selected }) => ($selected ? "2px solid #7d6cff" : "1px solid rgba(126,136,180,0.3)")};
border-radius: 14px;
background: rgba(16, 22, 52, 0.85);
padding: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.2s ease, border 0.2s ease;
&:hover {
transform: translateY(-2px);
}
`;
const GalleryThumbnail = styled.img`
width: 100%;
height: 100%;
border-radius: 10px;
object-fit: cover;
`;
const Helper = styled.span`
font-size: 0.85rem;
color: var(--text-muted);
`;
const ErrorText = styled.span`
font-size: 0.85rem;
color: #ff7b8a;
`;
const StatusMessage = styled.div`
padding: 12px;
border-radius: 12px;
background: rgba(12, 18, 42, 0.7);
border: 1px solid rgba(126, 136, 180, 0.24);
color: var(--text-muted);
font-size: 0.9rem;
`;
const DEFAULT_COLOR = "#5562ff";
export const ChildProfilePanel = ({
mode = "create",
child,
onCancel
}: ChildProfilePanelProps) => {
const isEdit = mode === "edit" && !!child;
const { createChild, updateChild } = useChildren();
const [fullName, setFullName] = useState<string>(child?.fullName ?? "");
const [colorHex, setColorHex] = useState<string>(child?.colorHex ?? DEFAULT_COLOR);
const [email, setEmail] = useState<string>(child?.email ?? "");
const [notes, setNotes] = useState<string>(child?.notes ?? "");
const [avatarSelection, setAvatarSelection] = useState<AvatarSelection | null>(null);
const [removeExistingAvatar, setRemoveExistingAvatar] = useState<boolean>(false);
const [avatarPickerOpen, setAvatarPickerOpen] = useState<boolean>(false);
const [gallery, setGallery] = useState<GalleryAvatar[]>([]);
const [galleryLoading, setGalleryLoading] = useState<boolean>(false);
const [galleryError, setGalleryError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (avatarSelection?.source === "upload") {
return () => {
URL.revokeObjectURL(avatarSelection.previewUrl);
};
}
return undefined;
}, [avatarSelection]);
useEffect(() => {
if (!gallery.length) {
setGalleryLoading(true);
listAvatars()
.then((items) => {
setGallery(items);
setGalleryError(null);
})
.catch(() => {
setGalleryError("Impossible de charger la galerie locale.");
})
.finally(() => setGalleryLoading(false));
}
}, [gallery.length]);
useEffect(() => {
if (isEdit && child) {
setFullName(child.fullName);
setColorHex(child.colorHex);
setEmail(child.email ?? "");
setNotes(child.notes ?? "");
setAvatarSelection(null);
setRemoveExistingAvatar(false);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
setError(null);
} else if (!isEdit) {
setFullName("");
setColorHex(DEFAULT_COLOR);
setEmail("");
setNotes("");
setAvatarSelection(null);
setRemoveExistingAvatar(false);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
setError(null);
}
}, [isEdit, child]);
const initials = useMemo(() => {
return fullName
.split(" ")
.filter(Boolean)
.map((part) => part.charAt(0))
.join("")
.slice(0, 2)
.toUpperCase();
}, [fullName]);
const currentAvatarUrl = useMemo(() => {
if (avatarSelection) {
return avatarSelection.source === "upload" ? avatarSelection.previewUrl : avatarSelection.url;
}
if (removeExistingAvatar) {
return null;
}
return child?.avatar?.url ?? null;
}, [avatarSelection, child?.avatar, removeExistingAvatar]);
const currentAvatarLabel = useMemo(() => {
if (avatarSelection) {
return avatarSelection.source === "upload"
? avatarSelection.name
: avatarSelection.name ?? "Avatar local";
}
if (removeExistingAvatar) {
return "Aucun avatar selectionne";
}
return child?.avatar?.name ?? "Aucun avatar selectionne";
}, [avatarSelection, child?.avatar, removeExistingAvatar]);
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) {
return;
}
if (!file.type.startsWith("image/")) {
setError("Le fichier doit etre une image (png, jpg, svg...).");
return;
}
setAvatarSelection({
source: "upload",
file,
previewUrl: URL.createObjectURL(file),
name: file.name
});
setRemoveExistingAvatar(false);
setError(null);
};
const handleSelectGallery = (item: GalleryAvatar) => {
setAvatarSelection({
source: "gallery",
url: item.url,
name: item.filename
});
setRemoveExistingAvatar(false);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const handleClearAvatar = () => {
if (avatarSelection?.source === "upload") {
URL.revokeObjectURL(avatarSelection.previewUrl);
}
setAvatarSelection(null);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
setRemoveExistingAvatar(isEdit);
};
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (isSubmitting) {
return;
}
if (!fullName.trim()) {
setError("Merci de saisir le nom complet de l enfant.");
return;
}
const payload: CreateChildPayload | UpdateChildPayload = {
fullName: fullName.trim(),
colorHex,
email: email.trim() ? email.trim() : undefined,
notes: notes.trim() ? notes.trim() : undefined
};
setIsSubmitting(true);
setError(null);
try {
let avatarPayload: ChildProfile["avatar"] | null | undefined = undefined;
if (avatarSelection?.source === "upload") {
const uploaded = await uploadAvatar(avatarSelection.file);
avatarPayload = {
kind: "custom",
url: uploaded.url,
name: avatarSelection.name
};
URL.revokeObjectURL(avatarSelection.previewUrl);
setAvatarSelection({
source: "gallery",
url: uploaded.url,
name: avatarSelection.name
});
} else if (avatarSelection?.source === "gallery") {
avatarPayload = {
kind: "custom",
url: avatarSelection.url,
name: avatarSelection.name
};
} else if (isEdit && child?.avatar && !removeExistingAvatar) {
avatarPayload = child.avatar;
} else if (removeExistingAvatar) {
avatarPayload = null;
}
if (avatarPayload !== undefined) {
payload.avatar = avatarPayload ?? undefined;
}
if (isEdit && child) {
await updateChild(child.id, payload as UpdateChildPayload);
onCancel?.();
} else {
await createChild(payload as CreateChildPayload);
setFullName("");
setColorHex(DEFAULT_COLOR);
setEmail("");
setNotes("");
setAvatarSelection(null);
setRemoveExistingAvatar(false);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
} catch (err) {
setError("Impossible d enregistrer pour le moment. Merci de reessayer.");
} finally {
setIsSubmitting(false);
}
};
return (
<Panel>
<Title>{isEdit ? "Modifier l enfant" : "Ajouter un enfant"}</Title>
<Description>
{isEdit
? "Ajuste le profil, la couleur ou l avatar. Les modifications sont visibles partout."
: "Cree rapidement un nouveau profil en renseignant email et avatar pour automatiser les agendas."}
</Description>
<Form onSubmit={handleSubmit}>
<Label>
Prenom et nom
<BaseInput
type="text"
placeholder="Ex: Alice Durand"
value={fullName}
onChange={(event) => setFullName(event.target.value)}
/>
</Label>
<Row>
<Label style={{ flex: "1 1 180px" }}>
Adresse email
<BaseInput
type="email"
placeholder="prenom@exemple.com"
value={email}
onChange={(event) => setEmail(event.target.value)}
/>
</Label>
<Label style={{ width: "120px" }}>
Couleur
<BaseInput
type="color"
value={colorHex}
onChange={(event) => setColorHex(event.target.value)}
/>
</Label>
</Row>
<Label>
Notes
<TextArea
placeholder="Infos importantes, allergies..."
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
</Label>
<AvatarSection>
<AvatarHeader>
<strong>Avatar</strong>
<Row>
<SecondaryButton
type="button"
onClick={() => setAvatarPickerOpen((open) => !open)}
>
{avatarPickerOpen ? "Fermer" : "Choisir un avatar"}
</SecondaryButton>
{(avatarSelection || (isEdit && child?.avatar && !removeExistingAvatar)) && (
<SecondaryButton type="button" onClick={handleClearAvatar}>
Retirer l avatar
</SecondaryButton>
)}
</Row>
</AvatarHeader>
<AvatarPreview>
{currentAvatarUrl ? (
<AvatarImage src={currentAvatarUrl} alt={currentAvatarLabel ?? "Avatar"} />
) : (
<AvatarFallback $color={colorHex}>{initials || "?"}</AvatarFallback>
)}
<AvatarInfo>
<span>{currentAvatarLabel}</span>
<Helper>
Les avatars importes sont stockes dans le dossier `backend/public/avatars/`.
</Helper>
</AvatarInfo>
</AvatarPreview>
{avatarPickerOpen ? (
<AvatarPicker>
<div>
<strong>Importer un nouvel avatar</strong>
<Label>
<BaseInput
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileChange}
/>
</Label>
<Helper>
Formats acceptes: png, jpg, svg. Taille conseillee 512x512. Les images importees sont
stockees localement.
</Helper>
</div>
<div>
<strong>Galerie locale</strong>
{galleryLoading ? (
<StatusMessage>Chargement de la galerie...</StatusMessage>
) : galleryError ? (
<StatusMessage>{galleryError}</StatusMessage>
) : gallery.length === 0 ? (
<StatusMessage>
Aucune image dans `backend/public/avatars/`. Ajoute des fichiers pour les proposer ici.
</StatusMessage>
) : (
<GalleryGrid>
{gallery.map((item) => (
<GalleryItem
key={item.filename}
$selected={
avatarSelection?.source === "gallery" && avatarSelection.url === item.url
}
type="button"
onClick={() => handleSelectGallery(item)}
>
<GalleryThumbnail src={item.url} alt={item.filename} />
</GalleryItem>
))}
</GalleryGrid>
)}
</div>
</AvatarPicker>
) : null}
</AvatarSection>
{error ? <ErrorText>{error}</ErrorText> : null}
<Row>
<SubmitButton type="submit" disabled={isSubmitting} $loading={isSubmitting}>
{isSubmitting
? "Enregistrement..."
: isEdit
? "Mettre a jour le profil"
: "Enregistrer le profil"}
</SubmitButton>
{isEdit ? (
<SecondaryButton
type="button"
onClick={() => {
onCancel?.();
}}
>
Annuler
</SecondaryButton>
) : null}
</Row>
</Form>
</Panel>
);
};

View File

@@ -0,0 +1,115 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import styled from "styled-components";
import { Link } from "react-router-dom";
import { Card, SectionTitle } from "@family-planner/ui";
const Header = styled.div `
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
`;
const DateBadge = styled.span `
padding: 8px 14px;
border-radius: 999px;
background: rgba(86, 115, 255, 0.16);
border: 1px solid rgba(86, 115, 255, 0.32);
color: #dfe6ff;
font-weight: 600;
font-size: 0.95rem;
text-transform: capitalize;
`;
const Columns = styled.div `
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
`;
const ColumnCard = styled.div `
padding: 18px;
border-radius: 16px;
background: rgba(16, 22, 52, 0.92);
border: 1px solid rgba(126, 136, 180, 0.22);
display: flex;
flex-direction: column;
gap: 14px;
box-shadow: 0 18px 32px ${({ $color }) => `${$color}1a`};
`;
const ColumnHeader = styled.div `
display: flex;
align-items: center;
gap: 12px;
`;
const Avatar = styled.div `
width: 44px;
height: 44px;
border-radius: 14px;
border: 2px solid ${({ $color }) => $color};
background: rgba(9, 13, 28, 0.85);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
color: #f4f5ff;
overflow: hidden;
flex-shrink: 0;
`;
const AvatarImage = styled.img `
width: 100%;
height: 100%;
object-fit: cover;
`;
const ColumnTitle = styled.div `
display: flex;
flex-direction: column;
gap: 4px;
`;
const ColumnName = styled.span `
font-weight: 600;
font-size: 1.05rem;
`;
const ColumnSub = styled.span `
font-size: 0.85rem;
color: var(--text-muted);
`;
const Activities = styled.div `
display: flex;
flex-direction: column;
gap: 10px;
`;
const ActivityItem = styled.div `
padding: 12px 14px;
border-radius: 12px;
background: ${({ $color }) => `${$color}12`};
border-left: 4px solid ${({ $color }) => $color};
display: flex;
flex-direction: column;
gap: 4px;
`;
const ActivityTitle = styled.span `
font-weight: 600;
`;
const ActivityTime = styled.span `
font-size: 0.9rem;
color: var(--text-muted);
`;
const EmptyState = styled.div `
margin-top: 18px;
padding: 22px;
border-radius: 16px;
background: rgba(12, 18, 42, 0.8);
border: 1px dashed rgba(126, 136, 180, 0.28);
color: var(--text-muted);
text-align: center;
`;
const EmptyColumn = styled.div `
padding: 12px;
border-radius: 12px;
background: rgba(12, 18, 42, 0.74);
border: 1px dashed rgba(126, 136, 180, 0.26);
color: var(--text-muted);
text-align: center;
font-size: 0.9rem;
`;
export const DailyScheduleGrid = ({ title, dateLabel, columns }) => {
return (_jsxs(Card, { children: [_jsxs(Header, { children: [_jsx(SectionTitle, { children: title }), _jsx(DateBadge, { children: dateLabel })] }), columns.length === 0 ? (_jsx(EmptyState, { children: "Aucun enfant actif pour afficher le planning du jour." })) : (_jsx(Columns, { children: columns.map((column) => (_jsxs(ColumnCard, { "$color": column.color, children: [_jsxs(ColumnHeader, { children: [_jsx(Avatar, { "$color": column.color, children: column.avatarUrl ? (_jsx(AvatarImage, { src: column.avatarUrl, alt: column.name })) : (column.initials) }), _jsxs(ColumnTitle, { children: [_jsx(ColumnName, { children: _jsx(Link, { to: `/children/${column.id}/planning`, style: { color: "inherit", textDecoration: "none" }, children: column.name }) }), _jsx(ColumnSub, { children: "Activites du jour" })] })] }), _jsx(Activities, { children: column.activities.length === 0 ? (_jsx(EmptyColumn, { children: "Aucune activite planifiee aujourd hui." })) : (column.activities.map((activity, index) => (_jsxs(ActivityItem, { "$color": column.color, children: [_jsx(ActivityTitle, { children: activity.title }), _jsx(ActivityTime, { children: activity.time }), activity.description ? _jsx("span", { children: activity.description }) : null] }, `${column.id}-${index}`)))) })] }, column.id))) }))] }));
};

View File

@@ -0,0 +1,200 @@
import styled from "styled-components";
import { Link } from "react-router-dom";
import { Card, SectionTitle } from "@family-planner/ui";
type DailyActivity = {
title: string;
time: string;
description?: string;
};
type DailyColumn = {
id: string;
name: string;
initials: string;
color: string;
avatarUrl?: string;
activities: DailyActivity[];
};
type DailyScheduleGridProps = {
title: string;
dateLabel: string;
columns: DailyColumn[];
};
const Header = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
`;
const DateBadge = styled.span`
padding: 8px 14px;
border-radius: 999px;
background: rgba(86, 115, 255, 0.16);
border: 1px solid rgba(86, 115, 255, 0.32);
color: #dfe6ff;
font-weight: 600;
font-size: 0.95rem;
text-transform: capitalize;
`;
const Columns = styled.div`
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
`;
const ColumnCard = styled.div<{ $color: string }>`
padding: 18px;
border-radius: 16px;
background: rgba(16, 22, 52, 0.92);
border: 1px solid rgba(126, 136, 180, 0.22);
display: flex;
flex-direction: column;
gap: 14px;
box-shadow: 0 18px 32px ${({ $color }) => `${$color}1a`};
`;
const ColumnHeader = styled.div`
display: flex;
align-items: center;
gap: 12px;
`;
const Avatar = styled.div<{ $color: string }>`
width: 44px;
height: 44px;
border-radius: 14px;
border: 2px solid ${({ $color }) => $color};
background: rgba(9, 13, 28, 0.85);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
color: #f4f5ff;
overflow: hidden;
flex-shrink: 0;
`;
const AvatarImage = styled.img`
width: 100%;
height: 100%;
object-fit: cover;
`;
const ColumnTitle = styled.div`
display: flex;
flex-direction: column;
gap: 4px;
`;
const ColumnName = styled.span`
font-weight: 600;
font-size: 1.05rem;
`;
const ColumnSub = styled.span`
font-size: 0.85rem;
color: var(--text-muted);
`;
const Activities = styled.div`
display: flex;
flex-direction: column;
gap: 10px;
`;
const ActivityItem = styled.div<{ $color: string }>`
padding: 12px 14px;
border-radius: 12px;
background: ${({ $color }) => `${$color}12`};
border-left: 4px solid ${({ $color }) => $color};
display: flex;
flex-direction: column;
gap: 4px;
`;
const ActivityTitle = styled.span`
font-weight: 600;
`;
const ActivityTime = styled.span`
font-size: 0.9rem;
color: var(--text-muted);
`;
const EmptyState = styled.div`
margin-top: 18px;
padding: 22px;
border-radius: 16px;
background: rgba(12, 18, 42, 0.8);
border: 1px dashed rgba(126, 136, 180, 0.28);
color: var(--text-muted);
text-align: center;
`;
const EmptyColumn = styled.div`
padding: 12px;
border-radius: 12px;
background: rgba(12, 18, 42, 0.74);
border: 1px dashed rgba(126, 136, 180, 0.26);
color: var(--text-muted);
text-align: center;
font-size: 0.9rem;
`;
export const DailyScheduleGrid = ({ title, dateLabel, columns }: DailyScheduleGridProps) => {
return (
<Card>
<Header>
<SectionTitle>{title}</SectionTitle>
<DateBadge>{dateLabel}</DateBadge>
</Header>
{columns.length === 0 ? (
<EmptyState>Aucun enfant actif pour afficher le planning du jour.</EmptyState>
) : (
<Columns>
{columns.map((column) => (
<ColumnCard key={column.id} $color={column.color}>
<ColumnHeader>
<Avatar $color={column.color}>
{column.avatarUrl ? (
<AvatarImage src={column.avatarUrl} alt={column.name} />
) : (
column.initials
)}
</Avatar>
<ColumnTitle>
<ColumnName>
<Link to={`/children/${column.id}/planning`} style={{ color: "inherit", textDecoration: "none" }}>
{column.name}
</Link>
</ColumnName>
<ColumnSub>Activites du jour</ColumnSub>
</ColumnTitle>
</ColumnHeader>
<Activities>
{column.activities.length === 0 ? (
<EmptyColumn>Aucune activite planifiee aujourd hui.</EmptyColumn>
) : (
column.activities.map((activity, index) => (
<ActivityItem key={`${column.id}-${index}`} $color={column.color}>
<ActivityTitle>{activity.title}</ActivityTitle>
<ActivityTime>{activity.time}</ActivityTime>
{activity.description ? <span>{activity.description}</span> : null}
</ActivityItem>
))
)}
</Activities>
</ColumnCard>
))}
</Columns>
)}
</Card>
);
};

View File

@@ -0,0 +1,119 @@
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
import { Component } from "react";
import styled from "styled-components";
const ErrorContainer = styled.div `
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
padding: 40px 20px;
text-align: center;
color: #e9ebff;
`;
const ErrorTitle = styled.h2 `
font-size: 24px;
font-weight: 600;
color: #ff3b30;
margin-bottom: 16px;
`;
const ErrorMessage = styled.p `
font-size: 16px;
color: #a8b0d3;
margin-bottom: 24px;
max-width: 600px;
`;
const ErrorDetails = styled.details `
margin-top: 20px;
padding: 16px;
background: rgba(255, 59, 48, 0.1);
border: 1px solid rgba(255, 59, 48, 0.2);
border-radius: 8px;
text-align: left;
max-width: 800px;
width: 100%;
summary {
cursor: pointer;
font-weight: 500;
margin-bottom: 12px;
color: #ff3b30;
}
pre {
font-size: 12px;
color: #e9ebff;
overflow-x: auto;
white-space: pre-wrap;
word-wrap: break-word;
}
`;
const ReloadButton = styled.button `
padding: 12px 24px;
font-size: 16px;
font-weight: 500;
color: white;
background: #5562ff;
border: none;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: #4451dd;
}
&:active {
background: #3340bb;
}
`;
/**
* Error Boundary component to catch and handle React errors
* Prevents the entire app from crashing when a component throws an error
*/
export class ErrorBoundary extends Component {
constructor(props) {
super(props);
Object.defineProperty(this, "handleReload", {
enumerable: true,
configurable: true,
writable: true,
value: () => {
// Clear error state and reload the page
window.location.reload();
}
});
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
// Update state so the next render will show the fallback UI
return {
hasError: true,
error
};
}
componentDidCatch(error, errorInfo) {
// Log error details to console in development
if (process.env.NODE_ENV === "development") {
console.error("ErrorBoundary caught an error:", error);
console.error("Error Info:", errorInfo);
}
// In production, you would send this to an error reporting service
// Example: Sentry.captureException(error, { extra: errorInfo });
this.setState({
error,
errorInfo
});
}
render() {
if (this.state.hasError) {
// Use custom fallback if provided
if (this.props.fallback) {
return this.props.fallback;
}
// Default error UI
return (_jsxs(ErrorContainer, { children: [_jsx(ErrorTitle, { children: "Oops! Something went wrong" }), _jsx(ErrorMessage, { children: "We're sorry for the inconvenience. An unexpected error occurred. Please try reloading the page." }), _jsx(ReloadButton, { onClick: this.handleReload, children: "Reload Page" }), process.env.NODE_ENV === "development" && this.state.error && (_jsxs(ErrorDetails, { children: [_jsx("summary", { children: "Error Details (Development Only)" }), _jsxs("pre", { children: [_jsx("strong", { children: "Error:" }), " ", this.state.error.toString(), "\n\n", _jsx("strong", { children: "Stack:" }), "\n", this.state.error.stack, "\n\n", this.state.errorInfo && (_jsxs(_Fragment, { children: [_jsx("strong", { children: "Component Stack:" }), "\n", this.state.errorInfo.componentStack] }))] })] }))] }));
}
return this.props.children;
}
}

View File

@@ -0,0 +1,39 @@
import { jsx as _jsx } from "react/jsx-runtime";
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
import { render, screen } from "@testing-library/react";
import { ErrorBoundary } from "./ErrorBoundary";
// Component that throws an error
const ThrowError = ({ shouldThrow }) => {
if (shouldThrow) {
throw new Error("Test error");
}
return _jsx("div", { children: "No error" });
};
describe("ErrorBoundary", () => {
// Suppress console.error for these tests
const originalError = console.error;
beforeAll(() => {
console.error = vi.fn();
});
afterAll(() => {
console.error = originalError;
});
it("renders children when there is no error", () => {
render(_jsx(ErrorBoundary, { children: _jsx("div", { children: "Test content" }) }));
expect(screen.getByText("Test content")).toBeInTheDocument();
});
it("renders error UI when child component throws", () => {
render(_jsx(ErrorBoundary, { children: _jsx(ThrowError, { shouldThrow: true }) }));
expect(screen.getByText(/Oops! Something went wrong/i)).toBeInTheDocument();
expect(screen.getByText(/Reload Page/i)).toBeInTheDocument();
});
it("renders children when child does not throw", () => {
render(_jsx(ErrorBoundary, { children: _jsx(ThrowError, { shouldThrow: false }) }));
expect(screen.getByText("No error")).toBeInTheDocument();
});
it("renders custom fallback when provided", () => {
const customFallback = _jsx("div", { children: "Custom error message" });
render(_jsx(ErrorBoundary, { fallback: customFallback, children: _jsx(ThrowError, { shouldThrow: true }) }));
expect(screen.getByText("Custom error message")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,66 @@
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
import { render, screen } from "@testing-library/react";
import { ErrorBoundary } from "./ErrorBoundary";
// Component that throws an error
const ThrowError = ({ shouldThrow }: { shouldThrow: boolean }) => {
if (shouldThrow) {
throw new Error("Test error");
}
return <div>No error</div>;
};
describe("ErrorBoundary", () => {
// Suppress console.error for these tests
const originalError = console.error;
beforeAll(() => {
console.error = vi.fn();
});
afterAll(() => {
console.error = originalError;
});
it("renders children when there is no error", () => {
render(
<ErrorBoundary>
<div>Test content</div>
</ErrorBoundary>
);
expect(screen.getByText("Test content")).toBeInTheDocument();
});
it("renders error UI when child component throws", () => {
render(
<ErrorBoundary>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
);
expect(screen.getByText(/Oops! Something went wrong/i)).toBeInTheDocument();
expect(screen.getByText(/Reload Page/i)).toBeInTheDocument();
});
it("renders children when child does not throw", () => {
render(
<ErrorBoundary>
<ThrowError shouldThrow={false} />
</ErrorBoundary>
);
expect(screen.getByText("No error")).toBeInTheDocument();
});
it("renders custom fallback when provided", () => {
const customFallback = <div>Custom error message</div>;
render(
<ErrorBoundary fallback={customFallback}>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
);
expect(screen.getByText("Custom error message")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,171 @@
import React, { Component, ErrorInfo, ReactNode } from "react";
import styled from "styled-components";
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
errorInfo?: ErrorInfo;
}
const ErrorContainer = styled.div`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
padding: 40px 20px;
text-align: center;
color: #e9ebff;
`;
const ErrorTitle = styled.h2`
font-size: 24px;
font-weight: 600;
color: #ff3b30;
margin-bottom: 16px;
`;
const ErrorMessage = styled.p`
font-size: 16px;
color: #a8b0d3;
margin-bottom: 24px;
max-width: 600px;
`;
const ErrorDetails = styled.details`
margin-top: 20px;
padding: 16px;
background: rgba(255, 59, 48, 0.1);
border: 1px solid rgba(255, 59, 48, 0.2);
border-radius: 8px;
text-align: left;
max-width: 800px;
width: 100%;
summary {
cursor: pointer;
font-weight: 500;
margin-bottom: 12px;
color: #ff3b30;
}
pre {
font-size: 12px;
color: #e9ebff;
overflow-x: auto;
white-space: pre-wrap;
word-wrap: break-word;
}
`;
const ReloadButton = styled.button`
padding: 12px 24px;
font-size: 16px;
font-weight: 500;
color: white;
background: #5562ff;
border: none;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: #4451dd;
}
&:active {
background: #3340bb;
}
`;
/**
* Error Boundary component to catch and handle React errors
* Prevents the entire app from crashing when a component throws an error
*/
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): State {
// Update state so the next render will show the fallback UI
return {
hasError: true,
error
};
}
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
// Log error details to console in development
if (process.env.NODE_ENV === "development") {
console.error("ErrorBoundary caught an error:", error);
console.error("Error Info:", errorInfo);
}
// In production, you would send this to an error reporting service
// Example: Sentry.captureException(error, { extra: errorInfo });
this.setState({
error,
errorInfo
});
}
handleReload = (): void => {
// Clear error state and reload the page
window.location.reload();
};
render(): ReactNode {
if (this.state.hasError) {
// Use custom fallback if provided
if (this.props.fallback) {
return this.props.fallback;
}
// Default error UI
return (
<ErrorContainer>
<ErrorTitle>Oops! Something went wrong</ErrorTitle>
<ErrorMessage>
We're sorry for the inconvenience. An unexpected error occurred.
Please try reloading the page.
</ErrorMessage>
<ReloadButton onClick={this.handleReload}>
Reload Page
</ReloadButton>
{process.env.NODE_ENV === "development" && this.state.error && (
<ErrorDetails>
<summary>Error Details (Development Only)</summary>
<pre>
<strong>Error:</strong> {this.state.error.toString()}
{"\n\n"}
<strong>Stack:</strong>
{"\n"}
{this.state.error.stack}
{"\n\n"}
{this.state.errorInfo && (
<>
<strong>Component Stack:</strong>
{"\n"}
{this.state.errorInfo.componentStack}
</>
)}
</pre>
</ErrorDetails>
)}
</ErrorContainer>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,88 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useState } from "react";
import { Link, useLocation } from "react-router-dom";
import styled from "styled-components";
const LayoutShell = styled.div `
display: grid;
grid-template-columns: 260px 1fr;
height: 100%;
background: rgba(10, 14, 34, 0.92);
backdrop-filter: blur(16px);
`;
const Sidebar = styled.nav `
padding: 32px 24px;
border-right: 1px solid rgba(126, 136, 180, 0.2);
display: flex;
flex-direction: column;
gap: 40px;
background: linear-gradient(180deg, rgba(26, 31, 64, 0.9), rgba(16, 20, 44, 0.82));
`;
const Logo = styled.div `
font-weight: 700;
font-size: 1.3rem;
letter-spacing: 1.2px;
text-transform: uppercase;
`;
const NavList = styled.ul `
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 12px;
`;
const NavItem = styled.li `
a {
display: block;
padding: 12px 16px;
border-radius: 12px;
background: ${({ $active }) => $active ? "rgba(85, 98, 255, 0.2)" : "transparent"};
color: ${({ $active }) => ($active ? "#ffffff" : "var(--text-muted)")};
font-weight: 600;
transition: background 0.2s ease, color 0.2s ease;
}
a:hover {
background: rgba(85, 98, 255, 0.28);
color: #ffffff;
}
`;
const Content = styled.main `
padding: 32px 40px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 24px;
`;
const FullscreenToggle = styled.button `
padding: 12px 18px;
border-radius: 12px;
border: none;
background: linear-gradient(135deg, #ff7a59, #ffb35f);
color: #1b1f3a;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
box-shadow: 0 12px 30px rgba(255, 139, 96, 0.3);
&:hover {
transform: translateY(-2px);
box-shadow: 0 18px 36px rgba(255, 139, 96, 0.36);
}
`;
export const Layout = ({ children }) => {
const location = useLocation();
const [isFullscreen, setIsFullscreen] = useState(false);
const handleToggleFullscreen = () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().then(() => {
setIsFullscreen(true);
});
}
else if (document.exitFullscreen) {
document.exitFullscreen().then(() => {
setIsFullscreen(false);
});
}
};
return (_jsxs(LayoutShell, { children: [_jsxs(Sidebar, { children: [_jsx(Logo, { children: "Family Planner" }), _jsxs(NavList, { children: [_jsx(NavItem, { "$active": location.pathname === "/", children: _jsx(Link, { to: "/", children: "Tableau de bord" }) }), _jsx(NavItem, { "$active": location.pathname.startsWith("/calendar/month"), children: _jsx(Link, { to: "/calendar/month", children: "Calendrier (mois)" }) }), _jsx(NavItem, { "$active": location.pathname === "/profiles" || location.pathname.startsWith("/profiles?"), children: _jsx(Link, { to: "/profiles", children: "Profils" }) }), _jsx(NavItem, { "$active": location.pathname === "/children/new" || location.pathname === "/people/new", children: _jsx(Link, { to: "/profiles/new", children: "Nouveau profil" }) }), _jsx(NavItem, { "$active": location.pathname.startsWith("/settings"), children: _jsx(Link, { to: "/settings", children: "Parametres" }) })] }), _jsx(FullscreenToggle, { onClick: handleToggleFullscreen, children: isFullscreen ? "Quitter plein ecran" : "Mode plein ecran" })] }), _jsx(Content, { children: children })] }));
};

View File

@@ -0,0 +1,135 @@
import { ReactNode, useState } from "react";
import { Link, useLocation } from "react-router-dom";
import styled from "styled-components";
type LayoutProps = {
children: ReactNode;
};
const LayoutShell = styled.div`
display: grid;
grid-template-columns: 260px 1fr;
height: 100%;
background: rgba(10, 14, 34, 0.92);
backdrop-filter: blur(16px);
`;
const Sidebar = styled.nav`
padding: 32px 24px;
border-right: 1px solid rgba(126, 136, 180, 0.2);
display: flex;
flex-direction: column;
gap: 40px;
background: linear-gradient(180deg, rgba(26, 31, 64, 0.9), rgba(16, 20, 44, 0.82));
`;
const Logo = styled.div`
font-weight: 700;
font-size: 1.3rem;
letter-spacing: 1.2px;
text-transform: uppercase;
`;
const NavList = styled.ul`
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 12px;
`;
const NavItem = styled.li<{ $active?: boolean }>`
a {
display: block;
padding: 12px 16px;
border-radius: 12px;
background: ${({ $active }) =>
$active ? "rgba(85, 98, 255, 0.2)" : "transparent"};
color: ${({ $active }) => ($active ? "#ffffff" : "var(--text-muted)")};
font-weight: 600;
transition: background 0.2s ease, color 0.2s ease;
}
a:hover {
background: rgba(85, 98, 255, 0.28);
color: #ffffff;
}
`;
const Content = styled.main`
padding: 32px 40px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 24px;
`;
const FullscreenToggle = styled.button`
padding: 12px 18px;
border-radius: 12px;
border: none;
background: linear-gradient(135deg, #ff7a59, #ffb35f);
color: #1b1f3a;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
box-shadow: 0 12px 30px rgba(255, 139, 96, 0.3);
&:hover {
transform: translateY(-2px);
box-shadow: 0 18px 36px rgba(255, 139, 96, 0.36);
}
`;
export const Layout = ({ children }: LayoutProps) => {
const location = useLocation();
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
const handleToggleFullscreen = () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().then(() => {
setIsFullscreen(true);
});
} else if (document.exitFullscreen) {
document.exitFullscreen().then(() => {
setIsFullscreen(false);
});
}
};
return (
<LayoutShell>
<Sidebar>
<Logo>Family Planner</Logo>
<NavList>
<NavItem $active={location.pathname === "/"}>
<Link to="/">Tableau de bord</Link>
</NavItem>
<NavItem $active={location.pathname.startsWith("/calendar/month")}>
<Link to="/calendar/month">Calendrier (mois)</Link>
</NavItem>
<NavItem
$active={
location.pathname === "/profiles" || location.pathname.startsWith("/profiles?")
}
>
<Link to="/profiles">Profils</Link>
</NavItem>
<NavItem $active={location.pathname === "/children/new" || location.pathname === "/people/new"}>
<Link to="/profiles/new">Nouveau profil</Link>
</NavItem>
<NavItem $active={location.pathname.startsWith("/settings")}>
<Link to="/settings">Parametres</Link>
</NavItem>
</NavList>
<FullscreenToggle onClick={handleToggleFullscreen}>
{isFullscreen ? "Quitter plein ecran" : "Mode plein ecran"}
</FullscreenToggle>
</Sidebar>
<Content>{children}</Content>
</LayoutShell>
);
};

View File

@@ -0,0 +1,324 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useEffect, useMemo, useRef, useState } from "react";
import styled from "styled-components";
import { uploadAvatar, listAvatars, createParent, updateParent, createGrandParent, updateGrandParent } from "../services/api-client";
const Panel = styled.aside `
flex: 1;
padding: 24px;
border-radius: 18px;
background: rgba(29, 36, 66, 0.92);
border: 1px solid rgba(126, 136, 180, 0.22);
display: flex;
flex-direction: column;
gap: 20px;
`;
const Title = styled.h2 `
margin: 0;
`;
const Description = styled.p `
margin: 0;
color: var(--text-muted);
`;
const Form = styled.form `
display: flex;
flex-direction: column;
gap: 18px;
`;
const Label = styled.label `
display: flex;
flex-direction: column;
gap: 8px;
font-weight: 600;
`;
const Row = styled.div `
display: flex;
gap: 12px;
flex-wrap: wrap;
`;
const BaseInput = styled.input `
padding: 12px 14px;
border-radius: 12px;
border: 1px solid rgba(126, 136, 180, 0.28);
background: rgba(16, 22, 52, 0.9);
color: #ffffff;
`;
const TextArea = styled.textarea `
padding: 12px 14px;
border-radius: 12px;
border: 1px solid rgba(126, 136, 180, 0.28);
min-height: 96px;
background: rgba(16, 22, 52, 0.9);
color: #ffffff;
`;
const SubmitButton = styled.button `
padding: 12px 16px;
border-radius: 12px;
border: none;
background: ${({ $loading }) => $loading ? "rgba(85, 98, 255, 0.25)" : "linear-gradient(135deg, #5562ff, #7d6cff)"};
color: #ffffff;
font-weight: 600;
cursor: ${({ $loading }) => ($loading ? "progress" : "pointer")};
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: opacity 0.2s ease;
opacity: ${({ $loading }) => ($loading ? 0.7 : 1)};
`;
const SecondaryButton = styled.button `
padding: 12px 16px;
border-radius: 12px;
border: 1px solid rgba(126, 136, 180, 0.4);
background: rgba(16, 22, 52, 0.9);
color: #d9dcff;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s ease;
&:hover {
transform: translateY(-1px);
}
`;
const AvatarSection = styled.section `
display: flex;
flex-direction: column;
gap: 16px;
`;
const AvatarHeader = styled.div `
display: flex;
align-items: center;
justify-content: space-between;
`;
const AvatarPreview = styled.div `
display: flex;
gap: 12px;
align-items: center;
`;
const AvatarImage = styled.img `
width: 72px;
height: 72px;
border-radius: 18px;
object-fit: cover;
border: 2px solid rgba(126, 136, 180, 0.28);
`;
const AvatarFallback = styled.div `
width: 72px;
height: 72px;
border-radius: 18px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(9, 13, 28, 0.8);
border: 2px solid ${({ $color }) => $color};
color: #f4f5ff;
font-weight: 700;
`;
const AvatarInfo = styled.div `
display: flex;
flex-direction: column;
gap: 4px;
`;
const Helper = styled.span `
color: var(--text-muted);
font-size: 0.9rem;
`;
const AvatarPicker = styled.div `
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
`;
const StatusMessage = styled.div `
padding: 12px;
border-radius: 12px;
border: 1px solid rgba(126, 136, 180, 0.3);
background: rgba(16, 22, 52, 0.7);
color: var(--text-muted);
`;
const GalleryGrid = styled.div `
display: grid;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 12px;
`;
const GalleryItem = styled.button `
border: 2px solid ${({ $selected }) => ($selected ? "#5562ff" : "transparent")};
border-radius: 12px;
overflow: hidden;
background: rgba(9, 13, 28, 0.8);
cursor: pointer;
`;
const GalleryThumbnail = styled.img `
display: block;
width: 100%;
height: 100%;
object-fit: cover;
`;
const ErrorText = styled.div `
color: #ffb3bd;
`;
const DEFAULT_COLOR = "#7d6cff";
export const ParentProfilePanel = ({ mode = "create", parent, onCancel, kind = "parent" }) => {
const isEdit = mode === "edit" && !!parent;
const resolvedKind = kind;
const createEntity = resolvedKind === 'grandparent' ? createGrandParent : createParent;
const updateEntity = resolvedKind === 'grandparent' ? updateGrandParent : updateParent;
const displayLabel = resolvedKind === 'grandparent' ? 'grand-parent' : 'parent';
const [fullName, setFullName] = useState("");
const [email, setEmail] = useState("");
const [colorHex, setColorHex] = useState(DEFAULT_COLOR);
const [notes, setNotes] = useState("");
const [avatarSelection, setAvatarSelection] = useState(null);
const [removeExistingAvatar, setRemoveExistingAvatar] = useState(false);
const [avatarPickerOpen, setAvatarPickerOpen] = useState(false);
const [gallery, setGallery] = useState([]);
const [galleryLoading, setGalleryLoading] = useState(false);
const [galleryError, setGalleryError] = useState(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState(null);
const fileInputRef = useRef(null);
useEffect(() => {
if (avatarPickerOpen && gallery.length === 0 && !galleryLoading) {
setGalleryLoading(true);
listAvatars()
.then((list) => setGallery(list.map((g) => ({ filename: g.filename, url: g.url }))))
.catch(() => setGalleryError("Impossible de charger la galerie locale."))
.finally(() => setGalleryLoading(false));
}
}, [avatarPickerOpen, gallery.length, galleryLoading]);
useEffect(() => {
if (isEdit && parent) {
setFullName(parent.fullName);
setColorHex(parent.colorHex);
setEmail(parent.email ?? "");
setNotes(parent.notes ?? "");
setAvatarSelection(null);
setRemoveExistingAvatar(false);
if (fileInputRef.current)
fileInputRef.current.value = "";
setError(null);
}
else if (!isEdit) {
setFullName("");
setColorHex(DEFAULT_COLOR);
setEmail("");
setNotes("");
setAvatarSelection(null);
setRemoveExistingAvatar(false);
if (fileInputRef.current)
fileInputRef.current.value = "";
setError(null);
}
}, [isEdit, parent]);
const initials = useMemo(() => {
return fullName
.split(" ")
.filter(Boolean)
.map((part) => part.charAt(0))
.join("")
.slice(0, 2)
.toUpperCase();
}, [fullName]);
const currentAvatarUrl = useMemo(() => {
if (avatarSelection) {
return avatarSelection.source === "upload" ? avatarSelection.previewUrl : avatarSelection.url;
}
if (removeExistingAvatar)
return null;
return parent?.avatar?.url ?? null;
}, [avatarSelection, parent?.avatar, removeExistingAvatar]);
const currentAvatarLabel = useMemo(() => {
if (avatarSelection) {
return avatarSelection.source === "upload" ? avatarSelection.name : avatarSelection.name ?? "Avatar local";
}
if (removeExistingAvatar)
return "Aucun avatar selectionne";
return parent?.avatar?.name ?? "Aucun avatar selectionne";
}, [avatarSelection, parent?.avatar, removeExistingAvatar]);
const handleFileChange = (event) => {
const file = event.target.files?.[0];
if (!file)
return;
if (!file.type.startsWith("image/")) {
setError("Le fichier doit etre une image (png, jpg, svg...).");
return;
}
setAvatarSelection({ source: "upload", file, previewUrl: URL.createObjectURL(file), name: file.name });
setRemoveExistingAvatar(false);
setError(null);
};
const handleSelectGallery = (item) => {
setAvatarSelection({ source: "gallery", url: item.url, name: item.filename });
setRemoveExistingAvatar(false);
if (fileInputRef.current)
fileInputRef.current.value = "";
};
const handleClearAvatar = () => {
if (avatarSelection?.source === "upload")
URL.revokeObjectURL(avatarSelection.previewUrl);
setAvatarSelection(null);
if (fileInputRef.current)
fileInputRef.current.value = "";
setRemoveExistingAvatar(isEdit);
};
const handleSubmit = async (event) => {
event.preventDefault();
if (isSubmitting)
return;
if (!fullName.trim()) {
setError("Merci de saisir le nom complet.");
return;
}
const payload = {
fullName: fullName.trim(),
colorHex,
email: email.trim() ? email.trim() : undefined,
notes: notes.trim() ? notes.trim() : undefined
};
setIsSubmitting(true);
setError(null);
try {
let avatarPayload = undefined;
if (avatarSelection?.source === "upload") {
const uploaded = await uploadAvatar(avatarSelection.file);
avatarPayload = { kind: "custom", url: uploaded.url, name: avatarSelection.name };
URL.revokeObjectURL(avatarSelection.previewUrl);
setAvatarSelection({ source: "gallery", url: uploaded.url, name: avatarSelection.name });
}
else if (avatarSelection?.source === "gallery") {
avatarPayload = { kind: "custom", url: avatarSelection.url, name: avatarSelection.name };
}
else if (isEdit && parent?.avatar && !removeExistingAvatar) {
avatarPayload = parent.avatar;
}
else if (removeExistingAvatar) {
avatarPayload = null;
}
if (avatarPayload !== undefined) {
payload.avatar = avatarPayload ?? undefined;
}
if (isEdit && parent) {
await updateEntity(parent.id, payload);
onCancel?.();
}
else {
await createEntity(payload);
setFullName("");
setColorHex(DEFAULT_COLOR);
setEmail("");
setNotes("");
setAvatarSelection(null);
setRemoveExistingAvatar(false);
if (fileInputRef.current)
fileInputRef.current.value = "";
}
}
catch (err) {
setError("Impossible d enregistrer pour le moment. Merci de reessayer.");
}
finally {
setIsSubmitting(false);
}
};
return (_jsxs(Panel, { children: [_jsx(Title, { children: isEdit ? "Modifier le parent" : "Ajouter un parent" }), _jsx(Description, { children: isEdit
? "Ajuste le profil, la couleur ou l avatar. Les modifications sont visibles partout."
: "Cree rapidement un nouveau profil en renseignant email et avatar." }), _jsxs(Form, { onSubmit: handleSubmit, children: [_jsxs(Label, { children: ["Prenom et nom", _jsx(BaseInput, { type: "text", placeholder: "Ex: Jean Dupont", value: fullName, onChange: (event) => setFullName(event.target.value) })] }), _jsxs(Row, { children: [_jsxs(Label, { style: { flex: "1 1 180px" }, children: ["Adresse email", _jsx(BaseInput, { type: "email", placeholder: "prenom@exemple.com", value: email, onChange: (event) => setEmail(event.target.value) })] }), _jsxs(Label, { style: { width: "120px" }, children: ["Couleur", _jsx(BaseInput, { type: "color", value: colorHex, onChange: (event) => setColorHex(event.target.value) })] })] }), _jsxs(Label, { children: ["Notes", _jsx(TextArea, { placeholder: "Infos importantes, telephone...", value: notes, onChange: (event) => setNotes(event.target.value) })] }), _jsxs(AvatarSection, { children: [_jsxs(AvatarHeader, { children: [_jsx("strong", { children: "Avatar" }), _jsxs(Row, { children: [_jsx(SecondaryButton, { type: "button", onClick: () => setAvatarPickerOpen((o) => !o), children: avatarPickerOpen ? "Fermer" : "Choisir un avatar" }), (avatarSelection || (isEdit && parent?.avatar && !removeExistingAvatar)) && (_jsx(SecondaryButton, { type: "button", onClick: handleClearAvatar, children: "Retirer l avatar" }))] })] }), _jsxs(AvatarPreview, { children: [currentAvatarUrl ? (_jsx(AvatarImage, { src: currentAvatarUrl, alt: currentAvatarLabel ?? "Avatar" })) : (_jsx(AvatarFallback, { "$color": colorHex, children: initials || "?" })), _jsxs(AvatarInfo, { children: [_jsx("span", { children: currentAvatarLabel }), _jsx(Helper, { children: "Les avatars importes sont stockes dans `backend/public/avatars/`." })] })] }), avatarPickerOpen ? (_jsxs(AvatarPicker, { children: [_jsxs("div", { children: [_jsx("strong", { children: "Importer un nouvel avatar" }), _jsx(Label, { children: _jsx(BaseInput, { ref: fileInputRef, type: "file", accept: "image/*", onChange: handleFileChange }) }), _jsx(Helper, { children: "Formats acceptes: png, jpg, svg. Taille conseillee 512x512. Les images importees sont stockees localement." })] }), _jsxs("div", { children: [_jsx("strong", { children: "Galerie locale" }), galleryLoading ? (_jsx(StatusMessage, { children: "Chargement de la galerie..." })) : galleryError ? (_jsx(StatusMessage, { children: galleryError })) : gallery.length === 0 ? (_jsx(StatusMessage, { children: "Aucune image dans `backend/public/avatars/`. Ajoute des fichiers pour les proposer ici." })) : (_jsx(GalleryGrid, { children: gallery.map((item) => (_jsx(GalleryItem, { "$selected": avatarSelection?.source === "gallery" && avatarSelection.url === item.url, type: "button", onClick: () => handleSelectGallery(item), children: _jsx(GalleryThumbnail, { src: item.url, alt: item.filename }) }, item.filename))) }))] })] })) : null] }), error ? _jsx(ErrorText, { children: error }) : null, _jsxs(Row, { children: [_jsx(SubmitButton, { type: "submit", disabled: isSubmitting, "$loading": isSubmitting, children: isSubmitting ? "Enregistrement..." : isEdit ? "Mettre a jour le profil" : "Enregistrer le profil" }), isEdit ? (_jsx(SecondaryButton, { type: "button", onClick: () => onCancel?.(), children: "Annuler" })) : null] })] })] }));
};

View File

@@ -0,0 +1,504 @@
import { ChangeEvent, FormEvent, useEffect, useMemo, useRef, useState } from "react";
import styled from "styled-components";
import { uploadAvatar, listAvatars, createParent, updateParent, createGrandParent, updateGrandParent } from "../services/api-client";
type ParentProfile = {
id: string;
fullName: string;
colorHex: string;
email?: string;
notes?: string;
avatar?: { kind: "preset" | "custom"; url: string; name?: string };
};
type ParentProfilePanelProps = {
kind?: 'parent' | 'grandparent';
mode?: 'create' | 'edit';
parent?: ParentProfile | null;
onCancel?: () => void;
};
type AvatarSelection =
| { source: "upload"; file: File; previewUrl: string; name: string }
| { source: "gallery"; url: string; name?: string };
type GalleryAvatar = {
filename: string;
url: string;
};
const Panel = styled.aside`
flex: 1;
padding: 24px;
border-radius: 18px;
background: rgba(29, 36, 66, 0.92);
border: 1px solid rgba(126, 136, 180, 0.22);
display: flex;
flex-direction: column;
gap: 20px;
`;
const Title = styled.h2`
margin: 0;
`;
const Description = styled.p`
margin: 0;
color: var(--text-muted);
`;
const Form = styled.form`
display: flex;
flex-direction: column;
gap: 18px;
`;
const Label = styled.label`
display: flex;
flex-direction: column;
gap: 8px;
font-weight: 600;
`;
const Row = styled.div`
display: flex;
gap: 12px;
flex-wrap: wrap;
`;
const BaseInput = styled.input`
padding: 12px 14px;
border-radius: 12px;
border: 1px solid rgba(126, 136, 180, 0.28);
background: rgba(16, 22, 52, 0.9);
color: #ffffff;
`;
const TextArea = styled.textarea`
padding: 12px 14px;
border-radius: 12px;
border: 1px solid rgba(126, 136, 180, 0.28);
min-height: 96px;
background: rgba(16, 22, 52, 0.9);
color: #ffffff;
`;
const SubmitButton = styled.button<{ $loading?: boolean }>`
padding: 12px 16px;
border-radius: 12px;
border: none;
background: ${({ $loading }) =>
$loading ? "rgba(85, 98, 255, 0.25)" : "linear-gradient(135deg, #5562ff, #7d6cff)"};
color: #ffffff;
font-weight: 600;
cursor: ${({ $loading }) => ($loading ? "progress" : "pointer")};
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: opacity 0.2s ease;
opacity: ${({ $loading }) => ($loading ? 0.7 : 1)};
`;
const SecondaryButton = styled.button`
padding: 12px 16px;
border-radius: 12px;
border: 1px solid rgba(126, 136, 180, 0.4);
background: rgba(16, 22, 52, 0.9);
color: #d9dcff;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s ease;
&:hover {
transform: translateY(-1px);
}
`;
const AvatarSection = styled.section`
display: flex;
flex-direction: column;
gap: 16px;
`;
const AvatarHeader = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
`;
const AvatarPreview = styled.div`
display: flex;
gap: 12px;
align-items: center;
`;
const AvatarImage = styled.img`
width: 72px;
height: 72px;
border-radius: 18px;
object-fit: cover;
border: 2px solid rgba(126, 136, 180, 0.28);
`;
const AvatarFallback = styled.div<{ $color: string }>`
width: 72px;
height: 72px;
border-radius: 18px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(9, 13, 28, 0.8);
border: 2px solid ${({ $color }) => $color};
color: #f4f5ff;
font-weight: 700;
`;
const AvatarInfo = styled.div`
display: flex;
flex-direction: column;
gap: 4px;
`;
const Helper = styled.span`
color: var(--text-muted);
font-size: 0.9rem;
`;
const AvatarPicker = styled.div`
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
`;
const StatusMessage = styled.div`
padding: 12px;
border-radius: 12px;
border: 1px solid rgba(126, 136, 180, 0.3);
background: rgba(16, 22, 52, 0.7);
color: var(--text-muted);
`;
const GalleryGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 12px;
`;
const GalleryItem = styled.button<{ $selected?: boolean }>`
border: 2px solid ${({ $selected }) => ($selected ? "#5562ff" : "transparent")};
border-radius: 12px;
overflow: hidden;
background: rgba(9, 13, 28, 0.8);
cursor: pointer;
`;
const GalleryThumbnail = styled.img`
display: block;
width: 100%;
height: 100%;
object-fit: cover;
`;
const ErrorText = styled.div`
color: #ffb3bd;
`;
const DEFAULT_COLOR = "#7d6cff";
export const ParentProfilePanel = ({ mode = "create", parent, onCancel, kind = "parent" }: ParentProfilePanelProps) => {
const isEdit = mode === "edit" && !!parent;
const resolvedKind = kind;
const createEntity = resolvedKind === 'grandparent' ? createGrandParent : createParent;
const updateEntity = resolvedKind === 'grandparent' ? updateGrandParent : updateParent;
const displayLabel = resolvedKind === 'grandparent' ? 'grand-parent' : 'parent';
const [fullName, setFullName] = useState<string>("");
const [email, setEmail] = useState<string>("");
const [colorHex, setColorHex] = useState<string>(DEFAULT_COLOR);
const [notes, setNotes] = useState<string>("");
const [avatarSelection, setAvatarSelection] = useState<AvatarSelection | null>(null);
const [removeExistingAvatar, setRemoveExistingAvatar] = useState<boolean>(false);
const [avatarPickerOpen, setAvatarPickerOpen] = useState<boolean>(false);
const [gallery, setGallery] = useState<GalleryAvatar[]>([]);
const [galleryLoading, setGalleryLoading] = useState<boolean>(false);
const [galleryError, setGalleryError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (avatarPickerOpen && gallery.length === 0 && !galleryLoading) {
setGalleryLoading(true);
listAvatars()
.then((list) => setGallery(list.map((g) => ({ filename: g.filename, url: g.url }))))
.catch(() => setGalleryError("Impossible de charger la galerie locale."))
.finally(() => setGalleryLoading(false));
}
}, [avatarPickerOpen, gallery.length, galleryLoading]);
useEffect(() => {
if (isEdit && parent) {
setFullName(parent.fullName);
setColorHex(parent.colorHex);
setEmail(parent.email ?? "");
setNotes(parent.notes ?? "");
setAvatarSelection(null);
setRemoveExistingAvatar(false);
if (fileInputRef.current) fileInputRef.current.value = "";
setError(null);
} else if (!isEdit) {
setFullName("");
setColorHex(DEFAULT_COLOR);
setEmail("");
setNotes("");
setAvatarSelection(null);
setRemoveExistingAvatar(false);
if (fileInputRef.current) fileInputRef.current.value = "";
setError(null);
}
}, [isEdit, parent]);
const initials = useMemo(() => {
return fullName
.split(" ")
.filter(Boolean)
.map((part) => part.charAt(0))
.join("")
.slice(0, 2)
.toUpperCase();
}, [fullName]);
const currentAvatarUrl = useMemo(() => {
if (avatarSelection) {
return avatarSelection.source === "upload" ? avatarSelection.previewUrl : avatarSelection.url;
}
if (removeExistingAvatar) return null;
return parent?.avatar?.url ?? null;
}, [avatarSelection, parent?.avatar, removeExistingAvatar]);
const currentAvatarLabel = useMemo(() => {
if (avatarSelection) {
return avatarSelection.source === "upload" ? avatarSelection.name : avatarSelection.name ?? "Avatar local";
}
if (removeExistingAvatar) return "Aucun avatar selectionne";
return parent?.avatar?.name ?? "Aucun avatar selectionne";
}, [avatarSelection, parent?.avatar, removeExistingAvatar]);
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.startsWith("image/")) {
setError("Le fichier doit etre une image (png, jpg, svg...).");
return;
}
setAvatarSelection({ source: "upload", file, previewUrl: URL.createObjectURL(file), name: file.name });
setRemoveExistingAvatar(false);
setError(null);
};
const handleSelectGallery = (item: GalleryAvatar) => {
setAvatarSelection({ source: "gallery", url: item.url, name: item.filename });
setRemoveExistingAvatar(false);
if (fileInputRef.current) fileInputRef.current.value = "";
};
const handleClearAvatar = () => {
if (avatarSelection?.source === "upload") URL.revokeObjectURL(avatarSelection.previewUrl);
setAvatarSelection(null);
if (fileInputRef.current) fileInputRef.current.value = "";
setRemoveExistingAvatar(isEdit);
};
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (isSubmitting) return;
if (!fullName.trim()) {
setError("Merci de saisir le nom complet.");
return;
}
const payload: any = {
fullName: fullName.trim(),
colorHex,
email: email.trim() ? email.trim() : undefined,
notes: notes.trim() ? notes.trim() : undefined
};
setIsSubmitting(true);
setError(null);
try {
let avatarPayload: ParentProfile["avatar"] | null | undefined = undefined;
if (avatarSelection?.source === "upload") {
const uploaded = await uploadAvatar(avatarSelection.file);
avatarPayload = { kind: "custom", url: uploaded.url, name: avatarSelection.name };
URL.revokeObjectURL(avatarSelection.previewUrl);
setAvatarSelection({ source: "gallery", url: uploaded.url, name: avatarSelection.name });
} else if (avatarSelection?.source === "gallery") {
avatarPayload = { kind: "custom", url: avatarSelection.url, name: avatarSelection.name };
} else if (isEdit && parent?.avatar && !removeExistingAvatar) {
avatarPayload = parent.avatar;
} else if (removeExistingAvatar) {
avatarPayload = null;
}
if (avatarPayload !== undefined) {
payload.avatar = avatarPayload ?? undefined;
}
if (isEdit && parent) {
await updateEntity(parent.id, payload);
onCancel?.();
} else {
await createEntity(payload);
setFullName("");
setColorHex(DEFAULT_COLOR);
setEmail("");
setNotes("");
setAvatarSelection(null);
setRemoveExistingAvatar(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
} catch (err) {
setError("Impossible d enregistrer pour le moment. Merci de reessayer.");
} finally {
setIsSubmitting(false);
}
};
return (
<Panel>
<Title>{isEdit ? "Modifier le parent" : "Ajouter un parent"}</Title>
<Description>
{isEdit
? "Ajuste le profil, la couleur ou l avatar. Les modifications sont visibles partout."
: "Cree rapidement un nouveau profil en renseignant email et avatar."}
</Description>
<Form onSubmit={handleSubmit}>
<Label>
Prenom et nom
<BaseInput
type="text"
placeholder="Ex: Jean Dupont"
value={fullName}
onChange={(event) => setFullName(event.target.value)}
/>
</Label>
<Row>
<Label style={{ flex: "1 1 180px" }}>
Adresse email
<BaseInput
type="email"
placeholder="prenom@exemple.com"
value={email}
onChange={(event) => setEmail(event.target.value)}
/>
</Label>
<Label style={{ width: "120px" }}>
Couleur
<BaseInput type="color" value={colorHex} onChange={(event) => setColorHex(event.target.value)} />
</Label>
</Row>
<Label>
Notes
<TextArea
placeholder="Infos importantes, telephone..."
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
</Label>
<AvatarSection>
<AvatarHeader>
<strong>Avatar</strong>
<Row>
<SecondaryButton type="button" onClick={() => setAvatarPickerOpen((o) => !o)}>
{avatarPickerOpen ? "Fermer" : "Choisir un avatar"}
</SecondaryButton>
{(avatarSelection || (isEdit && parent?.avatar && !removeExistingAvatar)) && (
<SecondaryButton type="button" onClick={handleClearAvatar}>
Retirer l avatar
</SecondaryButton>
)}
</Row>
</AvatarHeader>
<AvatarPreview>
{currentAvatarUrl ? (
<AvatarImage src={currentAvatarUrl} alt={currentAvatarLabel ?? "Avatar"} />
) : (
<AvatarFallback $color={colorHex}>{initials || "?"}</AvatarFallback>
)}
<AvatarInfo>
<span>{currentAvatarLabel}</span>
<Helper>Les avatars importes sont stockes dans `backend/public/avatars/`.</Helper>
</AvatarInfo>
</AvatarPreview>
{avatarPickerOpen ? (
<AvatarPicker>
<div>
<strong>Importer un nouvel avatar</strong>
<Label>
<BaseInput ref={fileInputRef} type="file" accept="image/*" onChange={handleFileChange} />
</Label>
<Helper>
Formats acceptes: png, jpg, svg. Taille conseillee 512x512. Les images importees sont stockees localement.
</Helper>
</div>
<div>
<strong>Galerie locale</strong>
{galleryLoading ? (
<StatusMessage>Chargement de la galerie...</StatusMessage>
) : galleryError ? (
<StatusMessage>{galleryError}</StatusMessage>
) : gallery.length === 0 ? (
<StatusMessage>
Aucune image dans `backend/public/avatars/`. Ajoute des fichiers pour les proposer ici.
</StatusMessage>
) : (
<GalleryGrid>
{gallery.map((item) => (
<GalleryItem
key={item.filename}
$selected={avatarSelection?.source === "gallery" && avatarSelection.url === item.url}
type="button"
onClick={() => handleSelectGallery(item)}
>
<GalleryThumbnail src={item.url} alt={item.filename} />
</GalleryItem>
))}
</GalleryGrid>
)}
</div>
</AvatarPicker>
) : null}
</AvatarSection>
{error ? <ErrorText>{error}</ErrorText> : null}
<Row>
<SubmitButton type="submit" disabled={isSubmitting} $loading={isSubmitting}>
{isSubmitting ? "Enregistrement..." : isEdit ? "Mettre a jour le profil" : "Enregistrer le profil"}
</SubmitButton>
{isEdit ? (
<SecondaryButton type="button" onClick={() => onCancel?.()}>Annuler</SecondaryButton>
) : null}
</Row>
</Form>
</Panel>
);
};

View File

@@ -0,0 +1,401 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useMemo, useRef, useState } from "react";
import styled from "styled-components";
const Backdrop = styled.div `
position: fixed;
inset: 0;
background: rgba(4, 6, 18, 0.72);
backdrop-filter: blur(12px);
display: flex;
align-items: flex-start;
justify-content: center;
padding: 48px 16px;
z-index: 1000;
overflow-y: auto;
`;
const Dialog = styled.div `
width: min(980px, 100%);
background: rgba(16, 22, 52, 0.96);
border-radius: 24px;
border: 1px solid rgba(126, 136, 180, 0.35);
display: flex;
flex-direction: column;
gap: 32px;
padding: 36px;
box-shadow: 0 32px 72px rgba(0, 0, 0, 0.45);
position: relative;
`;
const Header = styled.div `
display: flex;
flex-direction: column;
gap: 12px;
`;
const Title = styled.h1 `
margin: 0;
font-size: 1.8rem;
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: baseline;
`;
const Accent = styled.span `
font-size: 1rem;
color: rgba(197, 202, 240, 0.72);
text-transform: uppercase;
letter-spacing: 0.08em;
`;
const Description = styled.p `
margin: 0;
color: var(--text-muted);
line-height: 1.6;
`;
const CloseButton = styled.button `
position: absolute;
top: 24px;
right: 24px;
width: 36px;
height: 36px;
border-radius: 999px;
border: 1px solid rgba(126, 136, 180, 0.25);
background: rgba(12, 18, 40, 0.7);
color: #f4f4ff;
font-size: 1rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background: rgba(19, 28, 66, 0.8);
}
`;
const TwoColumns = styled.div `
display: grid;
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
gap: 24px;
`;
const SectionCard = styled.section `
border-radius: 20px;
padding: 24px;
border: 1px solid rgba(126, 136, 180, 0.28);
background: rgba(12, 18, 40, 0.88);
display: flex;
flex-direction: column;
gap: 18px;
`;
const SectionTitle = styled.h2 `
margin: 0;
font-size: 1.1rem;
`;
const SectionLead = styled.p `
margin: 0;
color: rgba(197, 202, 240, 0.75);
line-height: 1.5;
`;
const DropZone = styled.label `
border: 1px dashed rgba(126, 136, 180, 0.5);
border-radius: 16px;
padding: 24px;
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
justify-content: center;
background: ${({ $dragging }) => ($dragging ? "rgba(85, 98, 255, 0.18)" : "rgba(21, 28, 66, 0.6)")};
transition: background 0.2s ease, border-color 0.2s ease;
cursor: ${({ $disabled }) => ($disabled ? "not-allowed" : "pointer")};
text-align: center;
pointer-events: ${({ $disabled }) => ($disabled ? "none" : "auto")};
&:hover {
border-color: rgba(85, 98, 255, 0.65);
}
`;
const DropZoneHint = styled.span `
font-size: 0.9rem;
color: rgba(197, 202, 240, 0.65);
`;
const FileFormats = styled.span `
font-size: 0.8rem;
color: rgba(197, 202, 240, 0.48);
`;
const Separator = styled.div `
height: 1px;
background: rgba(126, 136, 180, 0.25);
margin: 8px 0;
`;
const ProviderToggle = styled.div `
display: inline-flex;
padding: 4px;
border-radius: 14px;
background: rgba(21, 28, 66, 0.7);
border: 1px solid rgba(126, 136, 180, 0.28);
gap: 4px;
`;
const ProviderButton = styled.button `
padding: 8px 16px;
border-radius: 10px;
border: none;
cursor: pointer;
font-weight: 600;
font-size: 0.9rem;
color: ${({ $active }) => ($active ? "#f4f5ff" : "rgba(197, 202, 240, 0.75)")};
background: ${({ $active }) => ($active ? "rgba(85, 98, 255, 0.4)" : "transparent")};
transition: background 0.2s ease, color 0.2s ease;
&:hover {
color: #f4f5ff;
}
`;
const Form = styled.form `
display: flex;
flex-direction: column;
gap: 16px;
`;
const FieldGroup = styled.div `
display: flex;
flex-direction: column;
gap: 8px;
`;
const Label = styled.label `
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: rgba(197, 202, 240, 0.72);
`;
const Input = styled.input `
border-radius: 12px;
border: 1px solid rgba(126, 136, 180, 0.3);
background: rgba(9, 13, 28, 0.8);
padding: 12px;
color: #f4f5ff;
font-size: 0.95rem;
&:focus {
outline: none;
border-color: rgba(85, 98, 255, 0.6);
box-shadow: 0 0 0 3px rgba(85, 98, 255, 0.18);
}
`;
const HelperText = styled.span `
font-size: 0.8rem;
color: rgba(197, 202, 240, 0.6);
`;
const CheckboxRow = styled.label `
display: inline-flex;
gap: 8px;
align-items: flex-start;
font-size: 0.85rem;
color: rgba(197, 202, 240, 0.75);
cursor: pointer;
input {
margin-top: 4px;
}
`;
const ActionRow = styled.div `
display: flex;
flex-wrap: wrap;
gap: 12px;
`;
const PrimaryButton = styled.button `
padding: 12px 18px;
border-radius: 12px;
border: 1px solid rgba(85, 98, 255, 0.55);
background: rgba(85, 98, 255, 0.22);
color: #f4f5ff;
font-weight: 600;
cursor: pointer;
transition: background 0.2s ease, transform 0.15s ease;
&:hover {
background: rgba(85, 98, 255, 0.32);
transform: translateY(-1px);
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
transform: none;
}
`;
const GhostButton = styled(PrimaryButton) `
background: rgba(21, 28, 66, 0.75);
border-color: rgba(126, 136, 180, 0.4);
&:hover {
background: rgba(41, 55, 120, 0.35);
}
`;
const ConnectionsHeader = styled.div `
display: flex;
align-items: center;
gap: 12px;
justify-content: space-between;
flex-wrap: wrap;
`;
const ConnectionsList = styled.ul `
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 12px;
`;
const ConnectionItem = styled.li `
display: flex;
align-items: center;
gap: 16px;
border-radius: 14px;
padding: 16px;
background: rgba(21, 28, 66, 0.72);
border: 1px solid rgba(126, 136, 180, 0.25);
flex-wrap: wrap;
`;
const ProviderBadge = styled.span `
padding: 6px 10px;
border-radius: 10px;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
background: rgba(85, 98, 255, 0.18);
border: 1px solid rgba(85, 98, 255, 0.45);
color: #f4f5ff;
`;
const ConnectionMeta = styled.div `
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
min-width: 220px;
`;
const ConnectionActions = styled.div `
display: inline-flex;
gap: 8px;
flex-wrap: wrap;
`;
const StatusPill = styled.span `
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-radius: 999px;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
background: ${({ $status }) => $status === "connected"
? "rgba(76, 175, 80, 0.16)"
: $status === "pending"
? "rgba(255, 193, 7, 0.18)"
: "rgba(244, 67, 54, 0.16)"};
border: 1px solid
${({ $status }) => $status === "connected"
? "rgba(76, 175, 80, 0.4)"
: $status === "pending"
? "rgba(255, 193, 7, 0.35)"
: "rgba(244, 67, 54, 0.4)"};
color: ${({ $status }) => $status === "connected" ? "#c8f7d3" : $status === "pending" ? "#ffe8b0" : "#ffccd2"};
`;
const DisconnectButton = styled.button `
padding: 8px 12px;
border-radius: 10px;
border: 1px solid rgba(255, 99, 132, 0.4);
background: rgba(255, 99, 132, 0.16);
color: #ffdce3;
cursor: pointer;
&:hover {
background: rgba(255, 99, 132, 0.26);
}
`;
const EmptyState = styled.div `
border-radius: 14px;
padding: 16px;
background: rgba(21, 28, 66, 0.72);
border: 1px dashed rgba(126, 136, 180, 0.35);
text-align: center;
color: rgba(197, 202, 240, 0.65);
font-size: 0.9rem;
`;
const Footer = styled.div `
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: flex-end;
`;
const LoadingHint = styled.span `
font-size: 0.8rem;
color: rgba(197, 202, 240, 0.65);
`;
const providerLabel = (provider) => (provider === "google" ? "Google" : "Outlook");
export const PlanningIntegrationDialog = ({ open, profileName, onClose, onImportFile, onConnectManual, onStartOAuth, onRefreshConnection, onReloadConnections, connections, onDisconnect, importing, connecting, connectionsLoading }) => {
const [isDragging, setIsDragging] = useState(false);
const [provider, setProvider] = useState("google");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [label, setLabel] = useState("");
const [shareWithFamily, setShareWithFamily] = useState(true);
const fileInputRef = useRef(null);
const isManualDisabled = connecting || !email || !password;
const dropzoneDisabled = importing ?? false;
const description = useMemo(() => `Centralise import de fichiers et connexions directes pour ${profileName}. Tu peux lier plusieurs agendas et ils seront disponibles pour les prochains imports.`, [profileName]);
const sortedConnections = useMemo(() => [...connections].sort((a, b) => {
if (a.status === b.status)
return (b.lastSyncedAt ?? "").localeCompare(a.lastSyncedAt ?? "");
if (a.status === "connected")
return -1;
if (b.status === "connected")
return 1;
if (a.status === "pending")
return -1;
if (b.status === "pending")
return 1;
return 0;
}), [connections]);
const handleFileSelected = async (file) => {
if (!file || !onImportFile)
return;
await onImportFile(file);
};
const handleManualConnect = async (event) => {
event.preventDefault();
if (!onConnectManual || !email || !password)
return;
await onConnectManual(provider, { email, password, label, shareWithFamily });
setEmail("");
setPassword("");
setLabel("");
};
const handleOAuth = async (targetProvider) => {
if (!onStartOAuth)
return;
await onStartOAuth(targetProvider);
};
if (!open)
return null;
return (_jsx(Backdrop, { onClick: onClose, children: _jsxs(Dialog, { onClick: (event) => event.stopPropagation(), children: [_jsx(CloseButton, { type: "button", onClick: onClose, "aria-label": "Fermer", children: "\u00D7" }), _jsxs(Header, { children: [_jsx(Accent, { children: "Importer & connecter" }), _jsxs(Title, { children: ["Centre de planning", _jsxs("span", { children: ["\u2022 ", profileName] })] }), _jsx(Description, { children: description })] }), _jsxs(TwoColumns, { children: [_jsxs(SectionCard, { children: [_jsx(SectionTitle, { children: "Importer un fichier de planning" }), _jsx(SectionLead, { children: "Notre IA analyse automatiquement ton document (PDF, Excel, image...) et extrait tous les \u00E9v\u00E9nements : dates, horaires, activit\u00E9s, lieux. Peu importe le format original, ton planning sera standardis\u00E9 et imm\u00E9diatement exploitable par l'application." }), _jsxs(DropZone, { "$dragging": isDragging, "$disabled": dropzoneDisabled, onDragOver: (event) => {
event.preventDefault();
if (dropzoneDisabled)
return;
setIsDragging(true);
}, onDragLeave: () => setIsDragging(false), onDrop: (event) => {
event.preventDefault();
setIsDragging(false);
if (dropzoneDisabled)
return;
const file = event.dataTransfer.files?.[0];
void handleFileSelected(file);
}, onClick: () => {
if (dropzoneDisabled)
return;
fileInputRef.current?.click();
}, children: [_jsx(DropZoneHint, { children: importing
? "🔍 Analyse intelligente en cours : détection et normalisation des événements..."
: "📄 Dépose ton fichier ici ou clique pour parcourir" }), _jsx(FileFormats, { children: "Formats support\u00E9s : PDF, Excel (.xls, .xlsx), CSV, Images (JPEG/PNG)" }), !importing && (_jsx(HelperText, { style: { marginTop: '8px', textAlign: 'center' }, children: "L'IA reconna\u00EEt automatiquement les dates, horaires et activit\u00E9s, quel que soit le format de ton planning." }))] }), _jsx("input", { ref: fileInputRef, type: "file", accept: "image/*,.pdf,.xls,.xlsx,.csv", style: { display: "none" }, onChange: (event) => {
const file = event.target.files?.[0];
void handleFileSelected(file);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
} })] }), _jsxs(SectionCard, { children: [_jsx(SectionTitle, { children: "Connecter un agenda" }), _jsx(SectionLead, { children: "Autorise Family Planner a lire les disponibilites et a creer des evenements synchronises. Tu peux connecter plusieurs agendas par profil." }), _jsxs(ProviderToggle, { children: [_jsx(ProviderButton, { type: "button", "$active": provider === "google", onClick: () => setProvider("google"), children: "Google Calendar" }), _jsx(ProviderButton, { type: "button", "$active": provider === "outlook", onClick: () => setProvider("outlook"), children: "Outlook 365" })] }), _jsxs(Form, { onSubmit: handleManualConnect, children: [_jsxs(FieldGroup, { children: [_jsx(Label, { htmlFor: "calendar-email", children: "Adresse email" }), _jsx(Input, { id: "calendar-email", type: "email", value: email, onChange: (event) => setEmail(event.target.value), placeholder: "prenom.nom@domaine.com", required: true })] }), _jsxs(FieldGroup, { children: [_jsx(Label, { htmlFor: "calendar-password", children: "Mot de passe applicatif" }), _jsx(Input, { id: "calendar-password", type: "password", value: password, onChange: (event) => setPassword(event.target.value), placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022", required: true }), _jsx(HelperText, { children: "Utilise un mot de passe applicatif dedie quand c'est possible pour securiser l'acces." })] }), _jsxs(FieldGroup, { children: [_jsx(Label, { htmlFor: "calendar-label", children: "Nom personnalise (optionnel)" }), _jsx(Input, { id: "calendar-label", value: label, onChange: (event) => setLabel(event.target.value), placeholder: "Agenda travail, Agenda perso..." })] }), _jsxs(CheckboxRow, { children: [_jsx("input", { type: "checkbox", checked: shareWithFamily, onChange: (event) => setShareWithFamily(event.target.checked) }), "Partager les evenements planifies avec les autres comptes parents/grands-parents."] }), _jsxs(ActionRow, { children: [_jsx(PrimaryButton, { type: "submit", disabled: isManualDisabled, children: connecting ? "Connexion en cours..." : `Connecter via identifiants ${providerLabel(provider)}` }), _jsx(GhostButton, { type: "button", onClick: () => handleOAuth("google"), disabled: connecting, children: "Continuer avec Google" }), _jsx(GhostButton, { type: "button", onClick: () => handleOAuth("outlook"), disabled: connecting, children: "Continuer avec Outlook" })] }), connecting ? _jsx(LoadingHint, { children: "Connexion securisee au fournisseur d'agenda..." }) : null] })] })] }), _jsx(Separator, {}), _jsxs(SectionCard, { children: [_jsxs(ConnectionsHeader, { children: [_jsxs("div", { children: [_jsx(SectionTitle, { children: "Agendas connectes" }), _jsx(SectionLead, { children: "Gere les autorisations actives. Chaque agenda connecte peut recevoir les synchronisations automatiques." })] }), onReloadConnections ? (_jsx(GhostButton, { type: "button", onClick: () => void onReloadConnections(), disabled: connectionsLoading, children: connectionsLoading ? "Actualisation..." : "Actualiser la liste" })) : null] }), connectionsLoading ? _jsx(LoadingHint, { children: "Chargement des connexions..." }) : null, sortedConnections.length === 0 ? (_jsx(EmptyState, { children: "Aucun agenda connecte pour l'instant." })) : (_jsx(ConnectionsList, { children: sortedConnections.map((connection) => (_jsxs(ConnectionItem, { children: [_jsx(ProviderBadge, { children: providerLabel(connection.provider) }), _jsxs(ConnectionMeta, { children: [_jsx("strong", { children: connection.label ?? connection.email }), _jsxs(HelperText, { children: [connection.email, connection.shareWithFamily === false ? " · Prive" : ""] }), _jsxs(HelperText, { children: ["Statut : ", _jsx(StatusPill, { "$status": connection.status, children: connection.status }), " \u2022", " ", connection.lastSyncedAt ? `Derniere synchro ${connection.lastSyncedAt}` : "Synchronisation a venir"] }), connection.scopes && connection.scopes.length > 0 ? (_jsxs(HelperText, { children: ["Scopes autorises : ", connection.scopes.join(", ")] })) : null] }), _jsxs(ConnectionActions, { children: [onRefreshConnection ? (_jsx(GhostButton, { type: "button", onClick: () => void onRefreshConnection(connection.id), disabled: connectionsLoading, children: "Relancer la synchro" })) : null, onDisconnect ? (_jsx(DisconnectButton, { type: "button", onClick: () => void onDisconnect(connection.id), children: "Supprimer" })) : null] })] }, connection.id))) }))] }), _jsx(Footer, { children: _jsx(GhostButton, { type: "button", onClick: onClose, children: "Fermer" }) })] }) }));
};

View File

@@ -0,0 +1,689 @@
import { FormEvent, useMemo, useRef, useState } from "react";
import styled from "styled-components";
import { CalendarProvider, ConnectedCalendar } from "../types/calendar";
type ManualConnectPayload = {
email: string;
password: string;
label?: string;
shareWithFamily: boolean;
};
type PlanningIntegrationDialogProps = {
open: boolean;
profileName: string;
onClose: () => void;
onImportFile?: (file: File) => Promise<void> | void;
onConnectManual?: (provider: CalendarProvider, payload: ManualConnectPayload) => Promise<void> | void;
onStartOAuth?: (provider: CalendarProvider) => Promise<void> | void;
onRefreshConnection?: (connectionId: string) => Promise<void> | void;
onReloadConnections?: () => Promise<void> | void;
connections: ConnectedCalendar[];
onDisconnect?: (connectionId: string) => Promise<void> | void;
importing?: boolean;
connecting?: boolean;
connectionsLoading?: boolean;
};
const Backdrop = styled.div`
position: fixed;
inset: 0;
background: rgba(4, 6, 18, 0.72);
backdrop-filter: blur(12px);
display: flex;
align-items: flex-start;
justify-content: center;
padding: 48px 16px;
z-index: 1000;
overflow-y: auto;
`;
const Dialog = styled.div`
width: min(980px, 100%);
background: rgba(16, 22, 52, 0.96);
border-radius: 24px;
border: 1px solid rgba(126, 136, 180, 0.35);
display: flex;
flex-direction: column;
gap: 32px;
padding: 36px;
box-shadow: 0 32px 72px rgba(0, 0, 0, 0.45);
position: relative;
`;
const Header = styled.div`
display: flex;
flex-direction: column;
gap: 12px;
`;
const Title = styled.h1`
margin: 0;
font-size: 1.8rem;
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: baseline;
`;
const Accent = styled.span`
font-size: 1rem;
color: rgba(197, 202, 240, 0.72);
text-transform: uppercase;
letter-spacing: 0.08em;
`;
const Description = styled.p`
margin: 0;
color: var(--text-muted);
line-height: 1.6;
`;
const CloseButton = styled.button`
position: absolute;
top: 24px;
right: 24px;
width: 36px;
height: 36px;
border-radius: 999px;
border: 1px solid rgba(126, 136, 180, 0.25);
background: rgba(12, 18, 40, 0.7);
color: #f4f4ff;
font-size: 1rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background: rgba(19, 28, 66, 0.8);
}
`;
const TwoColumns = styled.div`
display: grid;
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
gap: 24px;
`;
const SectionCard = styled.section`
border-radius: 20px;
padding: 24px;
border: 1px solid rgba(126, 136, 180, 0.28);
background: rgba(12, 18, 40, 0.88);
display: flex;
flex-direction: column;
gap: 18px;
`;
const SectionTitle = styled.h2`
margin: 0;
font-size: 1.1rem;
`;
const SectionLead = styled.p`
margin: 0;
color: rgba(197, 202, 240, 0.75);
line-height: 1.5;
`;
const DropZone = styled.label<{ $dragging: boolean; $disabled: boolean }>`
border: 1px dashed rgba(126, 136, 180, 0.5);
border-radius: 16px;
padding: 24px;
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
justify-content: center;
background: ${({ $dragging }) => ($dragging ? "rgba(85, 98, 255, 0.18)" : "rgba(21, 28, 66, 0.6)")};
transition: background 0.2s ease, border-color 0.2s ease;
cursor: ${({ $disabled }) => ($disabled ? "not-allowed" : "pointer")};
text-align: center;
pointer-events: ${({ $disabled }) => ($disabled ? "none" : "auto")};
&:hover {
border-color: rgba(85, 98, 255, 0.65);
}
`;
const DropZoneHint = styled.span`
font-size: 0.9rem;
color: rgba(197, 202, 240, 0.65);
`;
const FileFormats = styled.span`
font-size: 0.8rem;
color: rgba(197, 202, 240, 0.48);
`;
const Separator = styled.div`
height: 1px;
background: rgba(126, 136, 180, 0.25);
margin: 8px 0;
`;
const ProviderToggle = styled.div`
display: inline-flex;
padding: 4px;
border-radius: 14px;
background: rgba(21, 28, 66, 0.7);
border: 1px solid rgba(126, 136, 180, 0.28);
gap: 4px;
`;
const ProviderButton = styled.button<{ $active: boolean }>`
padding: 8px 16px;
border-radius: 10px;
border: none;
cursor: pointer;
font-weight: 600;
font-size: 0.9rem;
color: ${({ $active }) => ($active ? "#f4f5ff" : "rgba(197, 202, 240, 0.75)")};
background: ${({ $active }) => ($active ? "rgba(85, 98, 255, 0.4)" : "transparent")};
transition: background 0.2s ease, color 0.2s ease;
&:hover {
color: #f4f5ff;
}
`;
const Form = styled.form`
display: flex;
flex-direction: column;
gap: 16px;
`;
const FieldGroup = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
`;
const Label = styled.label`
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: rgba(197, 202, 240, 0.72);
`;
const Input = styled.input`
border-radius: 12px;
border: 1px solid rgba(126, 136, 180, 0.3);
background: rgba(9, 13, 28, 0.8);
padding: 12px;
color: #f4f5ff;
font-size: 0.95rem;
&:focus {
outline: none;
border-color: rgba(85, 98, 255, 0.6);
box-shadow: 0 0 0 3px rgba(85, 98, 255, 0.18);
}
`;
const HelperText = styled.span`
font-size: 0.8rem;
color: rgba(197, 202, 240, 0.6);
`;
const CheckboxRow = styled.label`
display: inline-flex;
gap: 8px;
align-items: flex-start;
font-size: 0.85rem;
color: rgba(197, 202, 240, 0.75);
cursor: pointer;
input {
margin-top: 4px;
}
`;
const ActionRow = styled.div`
display: flex;
flex-wrap: wrap;
gap: 12px;
`;
const PrimaryButton = styled.button`
padding: 12px 18px;
border-radius: 12px;
border: 1px solid rgba(85, 98, 255, 0.55);
background: rgba(85, 98, 255, 0.22);
color: #f4f5ff;
font-weight: 600;
cursor: pointer;
transition: background 0.2s ease, transform 0.15s ease;
&:hover {
background: rgba(85, 98, 255, 0.32);
transform: translateY(-1px);
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
transform: none;
}
`;
const GhostButton = styled(PrimaryButton)`
background: rgba(21, 28, 66, 0.75);
border-color: rgba(126, 136, 180, 0.4);
&:hover {
background: rgba(41, 55, 120, 0.35);
}
`;
const ConnectionsHeader = styled.div`
display: flex;
align-items: center;
gap: 12px;
justify-content: space-between;
flex-wrap: wrap;
`;
const ConnectionsList = styled.ul`
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 12px;
`;
const ConnectionItem = styled.li`
display: flex;
align-items: center;
gap: 16px;
border-radius: 14px;
padding: 16px;
background: rgba(21, 28, 66, 0.72);
border: 1px solid rgba(126, 136, 180, 0.25);
flex-wrap: wrap;
`;
const ProviderBadge = styled.span`
padding: 6px 10px;
border-radius: 10px;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
background: rgba(85, 98, 255, 0.18);
border: 1px solid rgba(85, 98, 255, 0.45);
color: #f4f5ff;
`;
const ConnectionMeta = styled.div`
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
min-width: 220px;
`;
const ConnectionActions = styled.div`
display: inline-flex;
gap: 8px;
flex-wrap: wrap;
`;
const StatusPill = styled.span<{ $status: "connected" | "pending" | "error" }>`
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-radius: 999px;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
background: ${({ $status }) =>
$status === "connected"
? "rgba(76, 175, 80, 0.16)"
: $status === "pending"
? "rgba(255, 193, 7, 0.18)"
: "rgba(244, 67, 54, 0.16)"};
border: 1px solid
${({ $status }) =>
$status === "connected"
? "rgba(76, 175, 80, 0.4)"
: $status === "pending"
? "rgba(255, 193, 7, 0.35)"
: "rgba(244, 67, 54, 0.4)"};
color: ${({ $status }) =>
$status === "connected" ? "#c8f7d3" : $status === "pending" ? "#ffe8b0" : "#ffccd2"};
`;
const DisconnectButton = styled.button`
padding: 8px 12px;
border-radius: 10px;
border: 1px solid rgba(255, 99, 132, 0.4);
background: rgba(255, 99, 132, 0.16);
color: #ffdce3;
cursor: pointer;
&:hover {
background: rgba(255, 99, 132, 0.26);
}
`;
const EmptyState = styled.div`
border-radius: 14px;
padding: 16px;
background: rgba(21, 28, 66, 0.72);
border: 1px dashed rgba(126, 136, 180, 0.35);
text-align: center;
color: rgba(197, 202, 240, 0.65);
font-size: 0.9rem;
`;
const Footer = styled.div`
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: flex-end;
`;
const LoadingHint = styled.span`
font-size: 0.8rem;
color: rgba(197, 202, 240, 0.65);
`;
const providerLabel = (provider: CalendarProvider) => (provider === "google" ? "Google" : "Outlook");
export const PlanningIntegrationDialog = ({
open,
profileName,
onClose,
onImportFile,
onConnectManual,
onStartOAuth,
onRefreshConnection,
onReloadConnections,
connections,
onDisconnect,
importing,
connecting,
connectionsLoading
}: PlanningIntegrationDialogProps) => {
const [isDragging, setIsDragging] = useState(false);
const [provider, setProvider] = useState<CalendarProvider>("google");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [label, setLabel] = useState("");
const [shareWithFamily, setShareWithFamily] = useState(true);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const isManualDisabled = connecting || !email || !password;
const dropzoneDisabled = importing ?? false;
const description = useMemo(
() =>
`Centralise import de fichiers et connexions directes pour ${profileName}. Tu peux lier plusieurs agendas et ils seront disponibles pour les prochains imports.`,
[profileName]
);
const sortedConnections = useMemo(
() =>
[...connections].sort((a, b) => {
if (a.status === b.status) return (b.lastSyncedAt ?? "").localeCompare(a.lastSyncedAt ?? "");
if (a.status === "connected") return -1;
if (b.status === "connected") return 1;
if (a.status === "pending") return -1;
if (b.status === "pending") return 1;
return 0;
}),
[connections]
);
const handleFileSelected = async (file?: File) => {
if (!file || !onImportFile) return;
await onImportFile(file);
};
const handleManualConnect = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!onConnectManual || !email || !password) return;
await onConnectManual(provider, { email, password, label, shareWithFamily });
setEmail("");
setPassword("");
setLabel("");
};
const handleOAuth = async (targetProvider: CalendarProvider) => {
if (!onStartOAuth) return;
await onStartOAuth(targetProvider);
};
if (!open) return null;
return (
<Backdrop onClick={onClose}>
<Dialog onClick={(event) => event.stopPropagation()}>
<CloseButton type="button" onClick={onClose} aria-label="Fermer">
×
</CloseButton>
<Header>
<Accent>Importer & connecter</Accent>
<Title>
Centre de planning
<span> {profileName}</span>
</Title>
<Description>{description}</Description>
</Header>
<TwoColumns>
<SectionCard>
<SectionTitle>Importer un fichier de planning</SectionTitle>
<SectionLead>
Notre IA analyse automatiquement ton document (PDF, Excel, image...) et extrait tous les événements :
dates, horaires, activités, lieux. Peu importe le format original, ton planning sera standardisé et
immédiatement exploitable par l'application.
</SectionLead>
<DropZone
$dragging={isDragging}
$disabled={dropzoneDisabled}
onDragOver={(event) => {
event.preventDefault();
if (dropzoneDisabled) return;
setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onDrop={(event) => {
event.preventDefault();
setIsDragging(false);
if (dropzoneDisabled) return;
const file = event.dataTransfer.files?.[0];
void handleFileSelected(file);
}}
onClick={() => {
if (dropzoneDisabled) return;
fileInputRef.current?.click();
}}
>
<DropZoneHint>
{importing
? "🔍 Analyse intelligente en cours : détection et normalisation des événements..."
: "📄 Dépose ton fichier ici ou clique pour parcourir"}
</DropZoneHint>
<FileFormats>
Formats supportés : PDF, Excel (.xls, .xlsx), CSV, Images (JPEG/PNG)
</FileFormats>
{!importing && (
<HelperText style={{ marginTop: '8px', textAlign: 'center' }}>
L'IA reconnaît automatiquement les dates, horaires et activités,
quel que soit le format de ton planning.
</HelperText>
)}
</DropZone>
<input
ref={fileInputRef}
type="file"
accept="image/*,.pdf,.xls,.xlsx,.csv"
style={{ display: "none" }}
onChange={(event) => {
const file = event.target.files?.[0];
void handleFileSelected(file);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}}
/>
</SectionCard>
<SectionCard>
<SectionTitle>Connecter un agenda</SectionTitle>
<SectionLead>
Autorise Family Planner a lire les disponibilites et a creer des evenements synchronises. Tu peux connecter
plusieurs agendas par profil.
</SectionLead>
<ProviderToggle>
<ProviderButton type="button" $active={provider === "google"} onClick={() => setProvider("google")}>
Google Calendar
</ProviderButton>
<ProviderButton type="button" $active={provider === "outlook"} onClick={() => setProvider("outlook")}>
Outlook 365
</ProviderButton>
</ProviderToggle>
<Form onSubmit={handleManualConnect}>
<FieldGroup>
<Label htmlFor="calendar-email">Adresse email</Label>
<Input
id="calendar-email"
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="prenom.nom@domaine.com"
required
/>
</FieldGroup>
<FieldGroup>
<Label htmlFor="calendar-password">Mot de passe applicatif</Label>
<Input
id="calendar-password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="••••••••"
required
/>
<HelperText>
Utilise un mot de passe applicatif dedie quand c'est possible pour securiser l'acces.
</HelperText>
</FieldGroup>
<FieldGroup>
<Label htmlFor="calendar-label">Nom personnalise (optionnel)</Label>
<Input
id="calendar-label"
value={label}
onChange={(event) => setLabel(event.target.value)}
placeholder="Agenda travail, Agenda perso..."
/>
</FieldGroup>
<CheckboxRow>
<input
type="checkbox"
checked={shareWithFamily}
onChange={(event) => setShareWithFamily(event.target.checked)}
/>
Partager les evenements planifies avec les autres comptes parents/grands-parents.
</CheckboxRow>
<ActionRow>
<PrimaryButton type="submit" disabled={isManualDisabled}>
{connecting ? "Connexion en cours..." : `Connecter via identifiants ${providerLabel(provider)}`}
</PrimaryButton>
<GhostButton type="button" onClick={() => handleOAuth("google")} disabled={connecting}>
Continuer avec Google
</GhostButton>
<GhostButton type="button" onClick={() => handleOAuth("outlook")} disabled={connecting}>
Continuer avec Outlook
</GhostButton>
</ActionRow>
{connecting ? <LoadingHint>Connexion securisee au fournisseur d'agenda...</LoadingHint> : null}
</Form>
</SectionCard>
</TwoColumns>
<Separator />
<SectionCard>
<ConnectionsHeader>
<div>
<SectionTitle>Agendas connectes</SectionTitle>
<SectionLead>
Gere les autorisations actives. Chaque agenda connecte peut recevoir les synchronisations automatiques.
</SectionLead>
</div>
{onReloadConnections ? (
<GhostButton type="button" onClick={() => void onReloadConnections()} disabled={connectionsLoading}>
{connectionsLoading ? "Actualisation..." : "Actualiser la liste"}
</GhostButton>
) : null}
</ConnectionsHeader>
{connectionsLoading ? <LoadingHint>Chargement des connexions...</LoadingHint> : null}
{sortedConnections.length === 0 ? (
<EmptyState>Aucun agenda connecte pour l'instant.</EmptyState>
) : (
<ConnectionsList>
{sortedConnections.map((connection) => (
<ConnectionItem key={connection.id}>
<ProviderBadge>{providerLabel(connection.provider)}</ProviderBadge>
<ConnectionMeta>
<strong>{connection.label ?? connection.email}</strong>
<HelperText>
{connection.email}
{connection.shareWithFamily === false ? " · Prive" : ""}
</HelperText>
<HelperText>
Statut : <StatusPill $status={connection.status}>{connection.status}</StatusPill> {" "}
{connection.lastSyncedAt ? `Derniere synchro ${connection.lastSyncedAt}` : "Synchronisation a venir"}
</HelperText>
{connection.scopes && connection.scopes.length > 0 ? (
<HelperText>Scopes autorises : {connection.scopes.join(", ")}</HelperText>
) : null}
</ConnectionMeta>
<ConnectionActions>
{onRefreshConnection ? (
<GhostButton
type="button"
onClick={() => void onRefreshConnection(connection.id)}
disabled={connectionsLoading}
>
Relancer la synchro
</GhostButton>
) : null}
{onDisconnect ? (
<DisconnectButton type="button" onClick={() => void onDisconnect(connection.id)}>
Supprimer
</DisconnectButton>
) : null}
</ConnectionActions>
</ConnectionItem>
))}
</ConnectionsList>
)}
</SectionCard>
<Footer>
<GhostButton type="button" onClick={onClose}>
Fermer
</GhostButton>
</Footer>
</Dialog>
</Backdrop>
);
};

View File

@@ -0,0 +1,67 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import styled from "styled-components";
const Actions = styled.div `
margin-left: auto;
display: flex;
gap: 12px;
align-items: center;
`;
const PrimaryButton = styled.button `
padding: 10px 14px;
border-radius: 12px;
background: rgba(85, 98, 255, 0.2);
border: 1px solid rgba(85, 98, 255, 0.45);
color: #f4f4ff;
font-weight: 600;
cursor: pointer;
transition: background 0.2s ease, transform 0.15s ease;
display: inline-flex;
align-items: center;
gap: 8px;
&:hover {
background: rgba(85, 98, 255, 0.3);
transform: translateY(-1px);
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
transform: none;
}
`;
const WarningButton = styled(PrimaryButton) `
background: rgba(255, 205, 86, 0.16);
border-color: rgba(255, 205, 86, 0.34);
color: #fff0c2;
&:hover {
background: rgba(255, 205, 86, 0.28);
}
`;
const DangerButton = styled(PrimaryButton) `
background: rgba(255, 99, 132, 0.16);
border-color: rgba(255, 99, 132, 0.34);
color: #ffd7dd;
&:hover {
background: rgba(255, 99, 132, 0.28);
}
`;
const Badge = styled.span `
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px 8px;
border-radius: 999px;
background: rgba(12, 18, 40, 0.85);
border: 1px solid rgba(196, 201, 240, 0.4);
color: #dbe0ff;
font-size: 0.75rem;
line-height: 1.2;
`;
export const ProfileActionBar = ({ onViewProfile, onView, onOpenPlanningCenter, onEdit, onDelete, importing, disabled, connectionsCount }) => {
const isPrimaryDisabled = disabled || importing;
const primaryLabel = importing ? "Analyse en cours..." : "Importer / connecter";
return (_jsxs(Actions, { children: [onViewProfile ? (_jsx(PrimaryButton, { type: "button", onClick: onViewProfile, children: "Voir profil" })) : null, onView ? (_jsx(PrimaryButton, { type: "button", onClick: onView, children: "Planning" })) : null, onOpenPlanningCenter ? (_jsxs(PrimaryButton, { type: "button", onClick: onOpenPlanningCenter, disabled: isPrimaryDisabled, children: [primaryLabel, connectionsCount ? _jsxs(Badge, { children: ["+", connectionsCount] }) : null] })) : null, onEdit ? (_jsx(WarningButton, { type: "button", onClick: onEdit, children: "Modifier" })) : null, onDelete ? (_jsx(DangerButton, { type: "button", onClick: onDelete, children: "Supprimer" })) : null] }));
};

View File

@@ -0,0 +1,126 @@
import styled from "styled-components";
type ProfileActionBarProps = {
onViewProfile?: () => void;
onView?: () => void;
onOpenPlanningCenter?: () => void;
onEdit?: () => void;
onDelete?: () => void;
importing?: boolean;
disabled?: boolean;
connectionsCount?: number;
};
const Actions = styled.div`
margin-left: auto;
display: flex;
gap: 12px;
align-items: center;
`;
const PrimaryButton = styled.button`
padding: 10px 14px;
border-radius: 12px;
background: rgba(85, 98, 255, 0.2);
border: 1px solid rgba(85, 98, 255, 0.45);
color: #f4f4ff;
font-weight: 600;
cursor: pointer;
transition: background 0.2s ease, transform 0.15s ease;
display: inline-flex;
align-items: center;
gap: 8px;
&:hover {
background: rgba(85, 98, 255, 0.3);
transform: translateY(-1px);
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
transform: none;
}
`;
const WarningButton = styled(PrimaryButton)`
background: rgba(255, 205, 86, 0.16);
border-color: rgba(255, 205, 86, 0.34);
color: #fff0c2;
&:hover {
background: rgba(255, 205, 86, 0.28);
}
`;
const DangerButton = styled(PrimaryButton)`
background: rgba(255, 99, 132, 0.16);
border-color: rgba(255, 99, 132, 0.34);
color: #ffd7dd;
&:hover {
background: rgba(255, 99, 132, 0.28);
}
`;
const Badge = styled.span`
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px 8px;
border-radius: 999px;
background: rgba(12, 18, 40, 0.85);
border: 1px solid rgba(196, 201, 240, 0.4);
color: #dbe0ff;
font-size: 0.75rem;
line-height: 1.2;
`;
export const ProfileActionBar = ({
onViewProfile,
onView,
onOpenPlanningCenter,
onEdit,
onDelete,
importing,
disabled,
connectionsCount
}: ProfileActionBarProps) => {
const isPrimaryDisabled = disabled || importing;
const primaryLabel = importing ? "Analyse en cours..." : "Importer / connecter";
return (
<Actions>
{onViewProfile ? (
<PrimaryButton type="button" onClick={onViewProfile}>
Voir profil
</PrimaryButton>
) : null}
{onView ? (
<PrimaryButton type="button" onClick={onView}>
Planning
</PrimaryButton>
) : null}
{onOpenPlanningCenter ? (
<PrimaryButton type="button" onClick={onOpenPlanningCenter} disabled={isPrimaryDisabled}>
{primaryLabel}
{connectionsCount ? <Badge>+{connectionsCount}</Badge> : null}
</PrimaryButton>
) : null}
{onEdit ? (
<WarningButton type="button" onClick={onEdit}>
Modifier
</WarningButton>
) : null}
{onDelete ? (
<DangerButton type="button" onClick={onDelete}>
Supprimer
</DangerButton>
) : null}
</Actions>
);
};

View File

@@ -0,0 +1,51 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import styled from "styled-components";
import { Card, SectionTitle } from "@family-planner/ui";
const Header = styled.div `
display: flex;
flex-direction: column;
gap: 6px;
`;
const Subtitle = styled.span `
color: var(--text-muted);
font-size: 0.95rem;
`;
const Grid = styled.div `
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 12px;
`;
const DayCard = styled.div `
padding: 16px;
border-radius: 14px;
background: rgba(16, 22, 52, 0.9);
border: 1px solid rgba(126, 136, 180, 0.24);
display: flex;
flex-direction: column;
gap: 12px;
`;
const DayLabel = styled.div `
font-weight: 600;
color: var(--text-muted);
`;
const Activity = styled.div `
padding: 10px 12px;
border-radius: 10px;
background: ${({ $color }) => `${$color}22`};
border-left: 4px solid ${({ $color }) => $color};
display: flex;
flex-direction: column;
gap: 4px;
`;
const EmptyState = styled.div `
margin-top: 12px;
padding: 20px;
border-radius: 14px;
background: rgba(12, 18, 42, 0.8);
border: 1px dashed rgba(126, 136, 180, 0.26);
color: var(--text-muted);
text-align: center;
`;
export const SampleScheduleGrid = ({ title, subtitle, entries }) => {
return (_jsxs(Card, { children: [_jsxs(Header, { children: [_jsx(SectionTitle, { children: title }), subtitle ? _jsx(Subtitle, { children: subtitle }) : null] }), entries.length > 0 ? (_jsx(Grid, { children: entries.map((entry) => (_jsxs(DayCard, { children: [_jsx(DayLabel, { children: entry.day }), _jsxs(Activity, { "$color": entry.color, children: [_jsx("strong", { children: entry.title }), _jsx("span", { children: entry.time }), entry.description ? _jsx("small", { children: entry.description }) : null] })] }, `${entry.day}-${entry.title}`))) })) : (_jsx(EmptyState, { children: "Aucune activite n est encore planifiee." }))] }));
};

View File

@@ -0,0 +1,95 @@
import styled from "styled-components";
import { Card, SectionTitle } from "@family-planner/ui";
type ScheduleEntry = {
day: string;
title: string;
color: string;
time: string;
description?: string;
};
type SampleScheduleGridProps = {
title: string;
subtitle?: string;
entries: ScheduleEntry[];
};
const Header = styled.div`
display: flex;
flex-direction: column;
gap: 6px;
`;
const Subtitle = styled.span`
color: var(--text-muted);
font-size: 0.95rem;
`;
const Grid = styled.div`
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 12px;
`;
const DayCard = styled.div`
padding: 16px;
border-radius: 14px;
background: rgba(16, 22, 52, 0.9);
border: 1px solid rgba(126, 136, 180, 0.24);
display: flex;
flex-direction: column;
gap: 12px;
`;
const DayLabel = styled.div`
font-weight: 600;
color: var(--text-muted);
`;
const Activity = styled.div<{ $color: string }>`
padding: 10px 12px;
border-radius: 10px;
background: ${({ $color }) => `${$color}22`};
border-left: 4px solid ${({ $color }) => $color};
display: flex;
flex-direction: column;
gap: 4px;
`;
const EmptyState = styled.div`
margin-top: 12px;
padding: 20px;
border-radius: 14px;
background: rgba(12, 18, 42, 0.8);
border: 1px dashed rgba(126, 136, 180, 0.26);
color: var(--text-muted);
text-align: center;
`;
export const SampleScheduleGrid = ({ title, subtitle, entries }: SampleScheduleGridProps) => {
return (
<Card>
<Header>
<SectionTitle>{title}</SectionTitle>
{subtitle ? <Subtitle>{subtitle}</Subtitle> : null}
</Header>
{entries.length > 0 ? (
<Grid>
{entries.map((entry) => (
<DayCard key={`${entry.day}-${entry.title}`}>
<DayLabel>{entry.day}</DayLabel>
<Activity $color={entry.color}>
<strong>{entry.title}</strong>
<span>{entry.time}</span>
{entry.description ? <small>{entry.description}</small> : null}
</Activity>
</DayCard>
))}
</Grid>
) : (
<EmptyState>Aucune activite n est encore planifiee.</EmptyState>
)}
</Card>
);
};

View File

@@ -0,0 +1,238 @@
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
import { useState, useEffect } from "react";
import styled from "styled-components";
import { Link } from "react-router-dom";
import { addOrUpdateAlert, removeAlert, hasAlert } from "../services/alert-service";
const Wrapper = styled.div `
display: grid;
grid-template-columns: 80px 1fr;
gap: 12px;
`;
const Hours = styled.div `
display: flex;
flex-direction: column;
position: relative;
`;
const HourLabel = styled.div `
height: 60px;
font-size: 0.85rem;
color: var(--text-muted);
`;
const Columns = styled.div `
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(200px, 1fr);
gap: 12px;
`;
const Col = styled.div `
border: 1px solid rgba(126, 136, 180, 0.22);
border-radius: 12px;
background: ${({ $color }) => `${$color}1A`};
display: flex;
flex-direction: column;
`;
const ColHeader = styled.div `
padding: 10px 12px;
border-bottom: 1px solid rgba(126, 136, 180, 0.22);
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
`;
const VacationBadge = styled.span `
padding: 2px 8px;
border-radius: 999px;
background: ${({ $color }) => `${$color}33`};
border: 1px solid ${({ $color }) => `${$color}66`};
color: #e9ebff;
font-size: 0.75rem;
font-weight: 600;
margin-left: auto;
`;
const ColBody = styled.div `
position: relative;
min-height: 60px; /* fallback */
`;
const Grid = styled.div `
position: relative;
`;
const GridLine = styled.div `
position: absolute;
left: 0;
right: 0;
height: 1px;
background: rgba(126, 136, 180, 0.2);
`;
const EventCard = styled.div `
position: absolute;
left: 6px;
right: 6px;
top: ${({ $top }) => `${$top}%`};
height: ${({ $height }) => `${$height}%`};
background: ${({ $color }) => `${$color}33`};
border-left: 3px solid ${({ $color }) => $color};
border-radius: 10px;
padding: 8px 10px;
display: flex;
flex-direction: column;
gap: 2px;
overflow: hidden;
cursor: pointer;
transition: background 0.2s ease, transform 0.1s ease;
&:hover {
background: ${({ $color }) => `${$color}44`};
transform: translateX(2px);
}
`;
const EventHeader = styled.div `
display: flex;
align-items: flex-start;
gap: 6px;
`;
const EventCheckbox = styled.input `
cursor: pointer;
width: 14px;
height: 14px;
margin-top: 2px;
flex-shrink: 0;
accent-color: var(--brand-primary);
`;
const EventTitle = styled.div `
font-weight: 600;
font-size: 0.9rem;
flex: 1;
`;
const EventMeta = styled.div `
color: var(--text-muted);
font-size: 0.8rem;
margin-left: 20px;
`;
const NowLine = styled.div `
position: absolute;
left: 0;
right: 0;
height: 2px;
background: #ff6b6b;
box-shadow: 0 0 6px rgba(255, 107, 107, 0.6);
`;
export const TimeGridMulti = ({ columns, startHour = 6, endHour = 22, showNowLine = true, now, timeZone }) => {
const [checkedEvents, setCheckedEvents] = useState(new Set());
const [, forceUpdate] = useState({});
// Initialiser les événements cochés au chargement
useEffect(() => {
const checked = new Set();
columns.forEach(col => {
col.events.forEach(ev => {
const eventId = ev.id || `${col.id}_${ev.startDateTime}`;
if (hasAlert(eventId, col.id)) {
checked.add(eventId);
}
});
});
setCheckedEvents(checked);
}, [columns]);
const handleCheckboxChange = (event, column, checked) => {
const eventId = event.id || `${column.id}_${event.startDateTime}`;
if (checked) {
addOrUpdateAlert(column.id, column.title, {
id: eventId,
title: event.title,
startDateTime: event.startDateTime,
endDateTime: event.endDateTime,
notes: event.notes
}, column.color);
setCheckedEvents(prev => new Set([...prev, eventId]));
}
else {
removeAlert(eventId, column.id);
setCheckedEvents(prev => {
const next = new Set(prev);
next.delete(eventId);
return next;
});
}
// Forcer un re-render pour mettre à jour les autres composants
forceUpdate({});
};
const totalMinutes = (endHour - startHour) * 60;
const hoursArray = [];
for (let h = startHour; h <= endHour; h++)
hoursArray.push(h);
const toMinutes = (iso) => {
const d = new Date(iso);
if (!timeZone) {
return d.getHours() * 60 + d.getMinutes();
}
const fmt = new Intl.DateTimeFormat("fr-FR", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone
});
const parts = fmt.formatToParts(d);
const hh = Number(parts.find((p) => p.type === "hour")?.value ?? "0");
const mm = Number(parts.find((p) => p.type === "minute")?.value ?? "0");
return hh * 60 + mm;
};
const clamp = (n, min, max) => Math.max(min, Math.min(max, n));
const nowDate = now ?? new Date();
const nowISODate = nowDate.toISOString().slice(0, 10);
const nowMins = timeZone
? (() => {
const fmt = new Intl.DateTimeFormat("fr-FR", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone
});
const parts = fmt.formatToParts(nowDate);
const hh = Number(parts.find((p) => p.type === "hour")?.value ?? "0");
const mm = Number(parts.find((p) => p.type === "minute")?.value ?? "0");
return hh * 60 + mm;
})()
: nowDate.getHours() * 60 + nowDate.getMinutes();
const nowTopPct = ((nowMins - startHour * 60) * 100) / totalMinutes;
return (_jsxs(Wrapper, { children: [_jsx(Hours, { children: hoursArray.map((h) => (_jsxs(HourLabel, { children: [String(h).padStart(2, "0"), ":00"] }, h))) }), _jsx(Columns, { children: columns.map((col) => (_jsxs(Col, { "$color": col.color, children: [_jsxs(ColHeader, { children: [col.avatarUrl ? (_jsx(MiniAvatar, { src: col.avatarUrl, alt: col.title })) : col.initials ? (_jsx(MiniFallback, { "$color": col.color, children: col.initials })) : null, col.link ? (_jsx(Link, { to: col.link, style: { color: "inherit", textDecoration: "none" }, children: col.title })) : (_jsx("span", { children: col.title })), col.vacationStatus ? (_jsx(VacationBadge, { "$color": col.color, children: col.vacationStatus })) : null] }), _jsx(ColBody, { children: _jsxs(Grid, { style: { height: `${totalMinutes}px` }, children: [hoursArray.map((h, idx) => (_jsx(GridLine, { style: { top: `${(idx * 60 * 100) / totalMinutes}%` } }, h))), showNowLine && nowTopPct >= 0 && nowTopPct <= 100 && ((col.dateISO ?? nowISODate) === nowISODate) ? (_jsx(NowLine, { style: { top: `${nowTopPct}%` } })) : null, col.events.map((ev, idx) => {
const start = toMinutes(ev.startDateTime);
const end = toMinutes(ev.endDateTime);
const s = clamp(start - startHour * 60, 0, totalMinutes);
const e = clamp(end - startHour * 60, 0, totalMinutes);
const height = Math.max(e - s, 20); // min height
const topPct = (s * 100) / totalMinutes;
const heightPct = (height * 100) / totalMinutes;
const color = ev.color || col.color;
const timeLabel = `${new Date(ev.startDateTime).toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })} - ${new Date(ev.endDateTime).toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })}`;
const eventId = ev.id || `${col.id}_${ev.startDateTime}`;
const isChecked = checkedEvents.has(eventId);
return (_jsxs(EventCard, { "$top": topPct, "$height": heightPct, "$color": color, onClick: (e) => {
// Si on clique sur la carte mais pas sur la checkbox
if (e.target.tagName !== 'INPUT') {
handleCheckboxChange(ev, col, !isChecked);
}
}, children: [_jsxs(EventHeader, { children: [_jsx(EventCheckbox, { type: "checkbox", checked: isChecked, onChange: (e) => {
e.stopPropagation();
handleCheckboxChange(ev, col, e.target.checked);
}, onClick: (e) => e.stopPropagation() }), _jsx(EventTitle, { children: ev.title })] }), _jsxs(EventMeta, { children: [timeLabel, ev.location ? ` • ${ev.location}` : ""] })] }, idx));
})] }) })] }, col.id))) })] }));
};
const MiniAvatar = styled.img `
width: 20px;
height: 20px;
border-radius: 50%;
object-fit: cover;
border: 1px solid rgba(0,0,0,0.2);
`;
const MiniFallback = styled.div `
width: 20px;
height: 20px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(0,0,0,0.25);
border: 1px solid ${({ $color }) => $color};
font-size: 0.7rem;
font-weight: 700;
`;

View File

@@ -0,0 +1,366 @@
import { useState, useEffect } from "react";
import styled from "styled-components";
import { Link } from "react-router-dom";
import { addOrUpdateAlert, removeAlert, hasAlert } from "../services/alert-service";
type GridEvent = {
id?: string;
title: string;
startDateTime: string;
endDateTime: string;
color?: string;
location?: string;
notes?: string;
};
type GridColumn = {
id: string;
title: string;
color: string;
events: GridEvent[];
dateISO?: string;
avatarUrl?: string;
initials?: string;
link?: string;
vacationStatus?: string | null;
};
type TimeGridMultiProps = {
columns: GridColumn[];
startHour?: number; // default 6
endHour?: number; // default 22
showNowLine?: boolean;
now?: Date;
timeZone?: string; // e.g. "Europe/Paris"
};
const Wrapper = styled.div`
display: grid;
grid-template-columns: 80px 1fr;
gap: 12px;
`;
const Hours = styled.div`
display: flex;
flex-direction: column;
position: relative;
`;
const HourLabel = styled.div`
height: 60px;
font-size: 0.85rem;
color: var(--text-muted);
`;
const Columns = styled.div`
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(200px, 1fr);
gap: 12px;
`;
const Col = styled.div<{ $color: string }>`
border: 1px solid rgba(126, 136, 180, 0.22);
border-radius: 12px;
background: ${({ $color }) => `${$color}1A`};
display: flex;
flex-direction: column;
`;
const ColHeader = styled.div`
padding: 10px 12px;
border-bottom: 1px solid rgba(126, 136, 180, 0.22);
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
`;
const VacationBadge = styled.span<{ $color: string }>`
padding: 2px 8px;
border-radius: 999px;
background: ${({ $color }) => `${$color}33`};
border: 1px solid ${({ $color }) => `${$color}66`};
color: #e9ebff;
font-size: 0.75rem;
font-weight: 600;
margin-left: auto;
`;
const ColBody = styled.div`
position: relative;
min-height: 60px; /* fallback */
`;
const Grid = styled.div`
position: relative;
`;
const GridLine = styled.div`
position: absolute;
left: 0;
right: 0;
height: 1px;
background: rgba(126, 136, 180, 0.2);
`;
const EventCard = styled.div<{ $top: number; $height: number; $color: string }>`
position: absolute;
left: 6px;
right: 6px;
top: ${({ $top }) => `${$top}%`};
height: ${({ $height }) => `${$height}%`};
background: ${({ $color }) => `${$color}33`};
border-left: 3px solid ${({ $color }) => $color};
border-radius: 10px;
padding: 8px 10px;
display: flex;
flex-direction: column;
gap: 2px;
overflow: hidden;
cursor: pointer;
transition: background 0.2s ease, transform 0.1s ease;
&:hover {
background: ${({ $color }) => `${$color}44`};
transform: translateX(2px);
}
`;
const EventHeader = styled.div`
display: flex;
align-items: flex-start;
gap: 6px;
`;
const EventCheckbox = styled.input`
cursor: pointer;
width: 14px;
height: 14px;
margin-top: 2px;
flex-shrink: 0;
accent-color: var(--brand-primary);
`;
const EventTitle = styled.div`
font-weight: 600;
font-size: 0.9rem;
flex: 1;
`;
const EventMeta = styled.div`
color: var(--text-muted);
font-size: 0.8rem;
margin-left: 20px;
`;
const NowLine = styled.div`
position: absolute;
left: 0;
right: 0;
height: 2px;
background: #ff6b6b;
box-shadow: 0 0 6px rgba(255, 107, 107, 0.6);
`;
export const TimeGridMulti = ({ columns, startHour = 6, endHour = 22, showNowLine = true, now, timeZone }: TimeGridMultiProps) => {
const [checkedEvents, setCheckedEvents] = useState<Set<string>>(new Set());
const [, forceUpdate] = useState({});
// Initialiser les événements cochés au chargement
useEffect(() => {
const checked = new Set<string>();
columns.forEach(col => {
col.events.forEach(ev => {
const eventId = ev.id || `${col.id}_${ev.startDateTime}`;
if (hasAlert(eventId, col.id)) {
checked.add(eventId);
}
});
});
setCheckedEvents(checked);
}, [columns]);
const handleCheckboxChange = (
event: GridEvent,
column: GridColumn,
checked: boolean
) => {
const eventId = event.id || `${column.id}_${event.startDateTime}`;
if (checked) {
addOrUpdateAlert(
column.id,
column.title,
{
id: eventId,
title: event.title,
startDateTime: event.startDateTime,
endDateTime: event.endDateTime,
notes: event.notes
},
column.color
);
setCheckedEvents(prev => new Set([...prev, eventId]));
} else {
removeAlert(eventId, column.id);
setCheckedEvents(prev => {
const next = new Set(prev);
next.delete(eventId);
return next;
});
}
// Forcer un re-render pour mettre à jour les autres composants
forceUpdate({});
};
const totalMinutes = (endHour - startHour) * 60;
const hoursArray = [] as number[];
for (let h = startHour; h <= endHour; h++) hoursArray.push(h);
const toMinutes = (iso: string) => {
const d = new Date(iso);
if (!timeZone) {
return d.getHours() * 60 + d.getMinutes();
}
const fmt = new Intl.DateTimeFormat("fr-FR", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone
});
const parts = fmt.formatToParts(d);
const hh = Number(parts.find((p) => p.type === "hour")?.value ?? "0");
const mm = Number(parts.find((p) => p.type === "minute")?.value ?? "0");
return hh * 60 + mm;
};
const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n));
const nowDate = now ?? new Date();
const nowISODate = nowDate.toISOString().slice(0, 10);
const nowMins = timeZone
? (() => {
const fmt = new Intl.DateTimeFormat("fr-FR", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone
});
const parts = fmt.formatToParts(nowDate);
const hh = Number(parts.find((p) => p.type === "hour")?.value ?? "0");
const mm = Number(parts.find((p) => p.type === "minute")?.value ?? "0");
return hh * 60 + mm;
})()
: nowDate.getHours() * 60 + nowDate.getMinutes();
const nowTopPct = ((nowMins - startHour * 60) * 100) / totalMinutes;
return (
<Wrapper>
<Hours>
{hoursArray.map((h) => (
<HourLabel key={h}>{String(h).padStart(2, "0")}:00</HourLabel>
))}
</Hours>
<Columns>
{columns.map((col) => (
<Col key={col.id} $color={col.color}>
<ColHeader>
{col.avatarUrl ? (
<MiniAvatar src={col.avatarUrl} alt={col.title} />
) : col.initials ? (
<MiniFallback $color={col.color}>{col.initials}</MiniFallback>
) : null}
{col.link ? (
<Link to={col.link} style={{ color: "inherit", textDecoration: "none" }}>
{col.title}
</Link>
) : (
<span>{col.title}</span>
)}
{col.vacationStatus ? (
<VacationBadge $color={col.color}>{col.vacationStatus}</VacationBadge>
) : null}
</ColHeader>
<ColBody>
<Grid style={{ height: `${totalMinutes}px` }}>
{hoursArray.map((h, idx) => (
<GridLine key={h} style={{ top: `${(idx * 60 * 100) / totalMinutes}%` }} />
))}
{showNowLine && nowTopPct >= 0 && nowTopPct <= 100 && ((col.dateISO ?? nowISODate) === nowISODate) ? (
<NowLine style={{ top: `${nowTopPct}%` }} />
) : null}
{col.events.map((ev, idx) => {
const start = toMinutes(ev.startDateTime);
const end = toMinutes(ev.endDateTime);
const s = clamp(start - startHour * 60, 0, totalMinutes);
const e = clamp(end - startHour * 60, 0, totalMinutes);
const height = Math.max(e - s, 20); // min height
const topPct = (s * 100) / totalMinutes;
const heightPct = (height * 100) / totalMinutes;
const color = ev.color || col.color;
const timeLabel = `${new Date(ev.startDateTime).toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })} - ${new Date(ev.endDateTime).toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })}`;
const eventId = ev.id || `${col.id}_${ev.startDateTime}`;
const isChecked = checkedEvents.has(eventId);
return (
<EventCard
key={idx}
$top={topPct}
$height={heightPct}
$color={color}
onClick={(e) => {
// Si on clique sur la carte mais pas sur la checkbox
if ((e.target as HTMLElement).tagName !== 'INPUT') {
handleCheckboxChange(ev, col, !isChecked);
}
}}
>
<EventHeader>
<EventCheckbox
type="checkbox"
checked={isChecked}
onChange={(e) => {
e.stopPropagation();
handleCheckboxChange(ev, col, e.target.checked);
}}
onClick={(e) => e.stopPropagation()}
/>
<EventTitle>{ev.title}</EventTitle>
</EventHeader>
<EventMeta>{timeLabel}{ev.location ? ` • ${ev.location}` : ""}</EventMeta>
</EventCard>
);
})}
</Grid>
</ColBody>
</Col>
))}
</Columns>
</Wrapper>
);
};
const MiniAvatar = styled.img`
width: 20px;
height: 20px;
border-radius: 50%;
object-fit: cover;
border: 1px solid rgba(0,0,0,0.2);
`;
const MiniFallback = styled.div<{ $color: string }>`
width: 20px;
height: 20px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(0,0,0,0.25);
border: 1px solid ${({ $color }) => $color};
font-size: 0.7rem;
font-weight: 700;
`;

View File

@@ -0,0 +1,69 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import styled, { keyframes } from "styled-components";
const ToastContext = createContext(undefined);
const slideIn = keyframes `
from { transform: translateY(10px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
`;
const ToastContainer = styled.div `
position: fixed;
bottom: 20px;
right: 20px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 9999;
`;
const ToastItem = styled.div `
min-width: 260px;
max-width: 420px;
padding: 12px 14px;
border-radius: 12px;
background: ${({ $level }) => $level === "success"
? "rgba(76, 217, 100, 0.15)"
: $level === "error"
? "rgba(255, 59, 48, 0.18)"
: "rgba(85, 98, 255, 0.15)"};
border: 1px solid
${({ $level }) => $level === "success"
? "rgba(76, 217, 100, 0.35)"
: $level === "error"
? "rgba(255, 59, 48, 0.35)"
: "rgba(85, 98, 255, 0.35)"};
color: #e9ebff;
animation: ${slideIn} 160ms ease-out;
`;
export const ToastProvider = ({ children }) => {
const [toasts, setToasts] = useState([]);
const addToast = useCallback((message, opts) => {
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const t = { id, message, level: opts?.level ?? "info", timeoutMs: opts?.timeoutMs ?? 3500 };
setToasts((prev) => [t, ...prev].slice(0, 5));
window.setTimeout(() => {
setToasts((prev) => prev.filter((x) => x.id !== id));
}, t.timeoutMs);
}, []);
const value = useMemo(() => ({ addToast }), [addToast]);
return (_jsxs(ToastContext.Provider, { value: value, children: [children, _jsx(Bridge, { addToast: addToast }), _jsx(ToastContainer, { children: toasts.map((t) => (_jsx(ToastItem, { "$level": t.level ?? "info", children: t.message }, t.id))) })] }));
};
export const useToasts = () => {
const ctx = useContext(ToastContext);
if (!ctx)
throw new Error("useToasts must be used within ToastProvider");
return ctx;
};
const Bridge = ({ addToast }) => {
useEffect(() => {
window.__fp_toast = (success, message) => addToast(message, { level: success ? "success" : "error" });
return () => {
try {
delete window.__fp_toast;
}
catch {
// Ignore errors when cleaning up
}
};
}, [addToast]);
return null;
};

View File

@@ -0,0 +1,96 @@
import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react";
import styled, { keyframes } from "styled-components";
type Toast = { id: string; message: string; level?: "info" | "success" | "error"; timeoutMs?: number };
type ToastContextValue = {
addToast: (message: string, opts?: { level?: Toast["level"]; timeoutMs?: number }) => void;
};
const ToastContext = createContext<ToastContextValue | undefined>(undefined);
const slideIn = keyframes`
from { transform: translateY(10px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
`;
const ToastContainer = styled.div`
position: fixed;
bottom: 20px;
right: 20px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 9999;
`;
const ToastItem = styled.div<{ $level: NonNullable<Toast["level"]> }>`
min-width: 260px;
max-width: 420px;
padding: 12px 14px;
border-radius: 12px;
background: ${({ $level }) =>
$level === "success"
? "rgba(76, 217, 100, 0.15)"
: $level === "error"
? "rgba(255, 59, 48, 0.18)"
: "rgba(85, 98, 255, 0.15)"};
border: 1px solid
${({ $level }) =>
$level === "success"
? "rgba(76, 217, 100, 0.35)"
: $level === "error"
? "rgba(255, 59, 48, 0.35)"
: "rgba(85, 98, 255, 0.35)"};
color: #e9ebff;
animation: ${slideIn} 160ms ease-out;
`;
export const ToastProvider = ({ children }: { children: ReactNode }) => {
const [toasts, setToasts] = useState<Toast[]>([]);
const addToast = useCallback((message: string, opts?: { level?: Toast["level"]; timeoutMs?: number }) => {
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const t: Toast = { id, message, level: opts?.level ?? "info", timeoutMs: opts?.timeoutMs ?? 3500 };
setToasts((prev) => [t, ...prev].slice(0, 5));
window.setTimeout(() => {
setToasts((prev) => prev.filter((x) => x.id !== id));
}, t.timeoutMs);
}, []);
const value = useMemo<ToastContextValue>(() => ({ addToast }), [addToast]);
return (
<ToastContext.Provider value={value}>
{children}
{/* Bridge for non-hook contexts */}
<Bridge addToast={addToast} />
<ToastContainer>
{toasts.map((t) => (
<ToastItem key={t.id} $level={t.level ?? "info"}>{t.message}</ToastItem>
))}
</ToastContainer>
</ToastContext.Provider>
);
};
export const useToasts = () => {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error("useToasts must be used within ToastProvider");
return ctx;
};
const Bridge = ({ addToast }: { addToast: (msg: string, opts?: { level?: "info" | "success" | "error" }) => void }) => {
useEffect(() => {
window.__fp_toast = (success: boolean, message: string) =>
addToast(message, { level: success ? "success" : "error" });
return () => {
try {
delete window.__fp_toast;
} catch {
// Ignore errors when cleaning up
}
};
}, [addToast]);
return null;
};

View File

@@ -0,0 +1,86 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useState, useEffect } from "react";
import styled from "styled-components";
import { Card, SectionTitle } from "@family-planner/ui";
import { getAllAlerts, getAlertsForProfile, purgeAlertsIfNeeded } from "../services/alert-service";
const AlertList = styled.div `
display: flex;
flex-direction: column;
gap: 16px;
`;
const AlertRow = styled.div `
padding: 16px;
border-radius: 14px;
background: ${({ $color }) => `${$color}20`};
border-left: 4px solid ${({ $color }) => $color};
display: flex;
flex-direction: column;
gap: 4px;
`;
const AlertTitle = styled.span `
font-weight: 600;
`;
const AlertMeta = styled.span `
color: var(--text-muted);
font-size: 0.9rem;
`;
const EmptyState = styled.div `
padding: 24px 18px;
border-radius: 14px;
border: 1px dashed rgba(126, 136, 180, 0.26);
color: var(--text-muted);
text-align: center;
`;
export const UpcomingAlerts = ({ activeChildId, loading }) => {
const [alerts, setAlerts] = useState([]);
const [lastUpdate, setLastUpdate] = useState(0);
// Purger automatiquement les alertes à minuit et les charger
useEffect(() => {
const loadAlerts = () => {
purgeAlertsIfNeeded();
if (activeChildId && activeChildId !== "all") {
setAlerts(getAlertsForProfile(activeChildId));
}
else {
setAlerts(getAllAlerts());
}
};
// Charger les alertes au montage
loadAlerts();
// Vérifier les alertes toutes les 10 secondes pour détecter les changements
const interval = setInterval(() => {
loadAlerts();
}, 10000);
// Vérifier à minuit pour purger automatiquement
const checkMidnight = () => {
const now = new Date();
const midnight = new Date(now);
midnight.setHours(24, 0, 0, 0);
const timeUntilMidnight = midnight.getTime() - now.getTime();
setTimeout(() => {
console.log("Purge automatique des alertes à minuit");
purgeAlertsIfNeeded();
loadAlerts();
// Relancer le timer pour le prochain minuit
checkMidnight();
}, timeUntilMidnight);
};
checkMidnight();
// Écouter les changements dans localStorage (entre onglets)
const handleStorageChange = (e) => {
if (e.key === "fp:alerts") {
loadAlerts();
}
};
window.addEventListener("storage", handleStorageChange);
return () => {
clearInterval(interval);
window.removeEventListener("storage", handleStorageChange);
};
}, [activeChildId, lastUpdate]);
// Recharger les alertes quand le profil actif change
useEffect(() => {
setLastUpdate(Date.now());
}, [activeChildId]);
return (_jsxs(Card, { children: [_jsx(SectionTitle, { children: "Alertes \u00E0 venir" }), loading ? (_jsx(EmptyState, { children: "Synchronisation..." })) : alerts.length === 0 ? (_jsxs(EmptyState, { children: ["Aucune alerte programm\u00E9e.", _jsx("br", {}), _jsx("span", { style: { fontSize: "0.85rem", marginTop: "8px", display: "block" }, children: "Cochez les activit\u00E9s dans l'agenda pour cr\u00E9er des alertes." })] })) : (_jsx(AlertList, { children: alerts.map((alert) => (_jsxs(AlertRow, { "$color": alert.color, children: [_jsx(AlertTitle, { children: alert.title }), _jsx(AlertMeta, { children: alert.time }), alert.note && _jsx(AlertMeta, { children: alert.note })] }, alert.id))) }))] }));
};

View File

@@ -0,0 +1,131 @@
import { useState, useEffect } from "react";
import styled from "styled-components";
import { Card, SectionTitle } from "@family-planner/ui";
import { getAllAlerts, getAlertsForProfile, purgeAlertsIfNeeded } from "../services/alert-service";
type UpcomingAlertsProps = {
activeChildId?: string;
loading?: boolean;
};
const AlertList = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
`;
const AlertRow = styled.div<{ $color: string }>`
padding: 16px;
border-radius: 14px;
background: ${({ $color }) => `${$color}20`};
border-left: 4px solid ${({ $color }) => $color};
display: flex;
flex-direction: column;
gap: 4px;
`;
const AlertTitle = styled.span`
font-weight: 600;
`;
const AlertMeta = styled.span`
color: var(--text-muted);
font-size: 0.9rem;
`;
const EmptyState = styled.div`
padding: 24px 18px;
border-radius: 14px;
border: 1px dashed rgba(126, 136, 180, 0.26);
color: var(--text-muted);
text-align: center;
`;
export const UpcomingAlerts = ({ activeChildId, loading }: UpcomingAlertsProps) => {
const [alerts, setAlerts] = useState<ReturnType<typeof getAllAlerts>>([]);
const [lastUpdate, setLastUpdate] = useState(0);
// Purger automatiquement les alertes à minuit et les charger
useEffect(() => {
const loadAlerts = () => {
purgeAlertsIfNeeded();
if (activeChildId && activeChildId !== "all") {
setAlerts(getAlertsForProfile(activeChildId));
} else {
setAlerts(getAllAlerts());
}
};
// Charger les alertes au montage
loadAlerts();
// Vérifier les alertes toutes les 10 secondes pour détecter les changements
const interval = setInterval(() => {
loadAlerts();
}, 10000);
// Vérifier à minuit pour purger automatiquement
const checkMidnight = () => {
const now = new Date();
const midnight = new Date(now);
midnight.setHours(24, 0, 0, 0);
const timeUntilMidnight = midnight.getTime() - now.getTime();
setTimeout(() => {
console.log("Purge automatique des alertes à minuit");
purgeAlertsIfNeeded();
loadAlerts();
// Relancer le timer pour le prochain minuit
checkMidnight();
}, timeUntilMidnight);
};
checkMidnight();
// Écouter les changements dans localStorage (entre onglets)
const handleStorageChange = (e: StorageEvent) => {
if (e.key === "fp:alerts") {
loadAlerts();
}
};
window.addEventListener("storage", handleStorageChange);
return () => {
clearInterval(interval);
window.removeEventListener("storage", handleStorageChange);
};
}, [activeChildId, lastUpdate]);
// Recharger les alertes quand le profil actif change
useEffect(() => {
setLastUpdate(Date.now());
}, [activeChildId]);
return (
<Card>
<SectionTitle>Alertes à venir</SectionTitle>
{loading ? (
<EmptyState>Synchronisation...</EmptyState>
) : alerts.length === 0 ? (
<EmptyState>
Aucune alerte programmée.<br />
<span style={{ fontSize: "0.85rem", marginTop: "8px", display: "block" }}>
Cochez les activités dans l'agenda pour créer des alertes.
</span>
</EmptyState>
) : (
<AlertList>
{alerts.map((alert) => (
<AlertRow key={alert.id} $color={alert.color}>
<AlertTitle>{alert.title}</AlertTitle>
<AlertMeta>{alert.time}</AlertMeta>
{alert.note && <AlertMeta>{alert.note}</AlertMeta>}
</AlertRow>
))}
</AlertList>
)}
</Card>
);
};

10
frontend/src/main.js Normal file
View File

@@ -0,0 +1,10 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import { GlobalStyle } from "./styles/global-style";
import { ChildrenProvider } from "./state/ChildrenContext";
import { ToastProvider } from "./components/ToastProvider";
import { ErrorBoundary } from "./components/ErrorBoundary";
ReactDOM.createRoot(document.getElementById("root")).render(_jsx(React.StrictMode, { children: _jsx(ErrorBoundary, { children: _jsx(BrowserRouter, { children: _jsx(ChildrenProvider, { children: _jsxs(ToastProvider, { children: [_jsx(GlobalStyle, {}), _jsx(App, {})] }) }) }) }) }));

23
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,23 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import { GlobalStyle } from "./styles/global-style";
import { ChildrenProvider } from "./state/ChildrenContext";
import { ToastProvider } from "./components/ToastProvider";
import { ErrorBoundary } from "./components/ErrorBoundary";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<ErrorBoundary>
<BrowserRouter>
<ChildrenProvider>
<ToastProvider>
<GlobalStyle />
<App />
</ToastProvider>
</ChildrenProvider>
</BrowserRouter>
</ErrorBoundary>
</React.StrictMode>
);

View File

@@ -0,0 +1,20 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import styled from "styled-components";
import { ChildProfilePanel } from "../components/ChildProfilePanel";
const Container = styled.div `
display: flex;
flex-direction: column;
gap: 16px;
max-width: 920px;
`;
const Title = styled.h1 `
margin: 0;
font-size: 1.8rem;
`;
const Description = styled.p `
margin: 0 0 12px;
color: var(--text-muted);
`;
export const AddChildScreen = () => {
return (_jsxs(Container, { children: [_jsx(Title, { children: "Ajouter un enfant" }), _jsx(Description, { children: "Renseigne les informations du profil puis enregistre." }), _jsx(ChildProfilePanel, {})] }));
};

View File

@@ -0,0 +1,30 @@
import styled from "styled-components";
import { ChildProfilePanel } from "../components/ChildProfilePanel";
const Container = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
max-width: 920px;
`;
const Title = styled.h1`
margin: 0;
font-size: 1.8rem;
`;
const Description = styled.p`
margin: 0 0 12px;
color: var(--text-muted);
`;
export const AddChildScreen = () => {
return (
<Container>
<Title>Ajouter un enfant</Title>
<Description>Renseigne les informations du profil puis enregistre.</Description>
<ChildProfilePanel />
</Container>
);
};

View File

@@ -0,0 +1,35 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useState } from "react";
import styled from "styled-components";
import { ChildProfilePanel } from "../components/ChildProfilePanel";
import { ParentProfilePanel } from "../components/ParentProfilePanel";
const Container = styled.div `
display: flex;
flex-direction: column;
gap: 16px;
max-width: 920px;
`;
const Title = styled.h1 `
margin: 0;
font-size: 1.8rem;
`;
const Description = styled.p `
margin: 0 0 12px;
color: var(--text-muted);
`;
const Toggle = styled.div `
display: inline-flex;
gap: 8px;
`;
const ToggleBtn = styled.button `
padding: 8px 12px;
border-radius: 10px;
border: 1px solid rgba(126, 136, 180, 0.25);
background: ${({ $active }) => ($active ? "rgba(85,98,255,0.2)" : "transparent")};
color: #e9ebff;
cursor: pointer;
`;
export const AddPersonScreen = () => {
const [mode, setMode] = useState("child");
return (_jsxs(Container, { children: [_jsx(Title, { children: "Nouveau profil" }), _jsx(Description, { children: "Ajoute un enfant, un parent ou un grand\u2011parent. Tous apparaissent dans l'onglet Profils." }), _jsxs(Toggle, { children: [_jsx(ToggleBtn, { "$active": mode === "child", onClick: () => setMode("child"), children: "Enfant" }), _jsx(ToggleBtn, { "$active": mode === "parent", onClick: () => setMode("parent"), children: "Parent" }), _jsx(ToggleBtn, { "$active": mode === "grandparent", onClick: () => setMode("grandparent"), children: "Grand\u2011parent" })] }), mode === "child" ? _jsx(ChildProfilePanel, {}) : _jsx(ParentProfilePanel, { kind: mode === "parent" ? "parent" : "grandparent" })] }));
};

View File

@@ -0,0 +1,70 @@
import { useState } from "react";
import styled from "styled-components";
import { ChildProfilePanel } from "../components/ChildProfilePanel";
import { ParentProfilePanel } from "../components/ParentProfilePanel";
const Container = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
max-width: 920px;
`;
const Title = styled.h1`
margin: 0;
font-size: 1.8rem;
`;
const Description = styled.p`
margin: 0 0 12px;
color: var(--text-muted);
`;
const Toggle = styled.div`
display: inline-flex;
gap: 8px;
`;
const ToggleBtn = styled.button<{ $active?: boolean }>`
padding: 8px 12px;
border-radius: 10px;
border: 1px solid rgba(126, 136, 180, 0.25);
background: ${({ $active }) => ($active ? "rgba(85,98,255,0.2)" : "transparent")};
color: #e9ebff;
cursor: pointer;
`;
export const AddPersonScreen = () => {
const [mode, setMode] = useState<"child" | "parent" | "grandparent">("child");
return (
<Container>
<Title>Nouveau profil</Title>
<Description>Ajoute un enfant, un parent ou un grandparent. Tous apparaissent dans l'onglet Profils.</Description>
<Toggle>
<ToggleBtn $active={mode === "child"} onClick={() => setMode("child")}>
Enfant
</ToggleBtn>
<ToggleBtn $active={mode === "parent"} onClick={() => setMode("parent")}>
Parent
</ToggleBtn>
<ToggleBtn $active={mode === "grandparent"} onClick={() => setMode("grandparent")}>
Grandparent
</ToggleBtn>
</Toggle>
{mode === "child" ? <ChildProfilePanel /> : <ParentProfilePanel kind={mode === "parent" ? "parent" : "grandparent"} />}
</Container>
);
};

View File

@@ -0,0 +1,359 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import styled from "styled-components";
const Container = styled.div `
max-width: 1200px;
margin: 0 auto;
`;
const Header = styled.header `
display: flex;
align-items: center;
gap: 24px;
margin-bottom: 32px;
padding: 24px;
border-radius: 18px;
background: rgba(29, 36, 66, 0.92);
border: 1px solid rgba(126, 136, 180, 0.22);
`;
const BackButton = styled.button `
padding: 10px 16px;
border-radius: 12px;
border: 1px solid rgba(126, 136, 180, 0.4);
background: rgba(16, 22, 52, 0.9);
color: #d9dcff;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s ease;
&:hover {
transform: translateY(-1px);
}
`;
const Avatar = styled.div `
width: 96px;
height: 96px;
border-radius: 24px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 2rem;
background: ${({ $imageUrl, $color }) => $imageUrl ? `url(${$imageUrl}) center/cover` : `rgba(9, 13, 28, 0.8)`};
color: ${({ $imageUrl }) => ($imageUrl ? "transparent" : "#f4f5ff")};
border: 3px solid ${({ $color }) => $color};
`;
const HeaderInfo = styled.div `
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
`;
const Title = styled.h1 `
margin: 0;
font-size: 2rem;
`;
const Meta = styled.p `
margin: 0;
color: var(--text-muted);
`;
const Content = styled.div `
display: grid;
gap: 24px;
`;
const Card = styled.section `
padding: 24px;
border-radius: 18px;
background: rgba(29, 36, 66, 0.92);
border: 1px solid rgba(126, 136, 180, 0.22);
display: flex;
flex-direction: column;
gap: 20px;
`;
const CardTitle = styled.h2 `
margin: 0;
font-size: 1.4rem;
display: flex;
align-items: center;
justify-content: space-between;
`;
const Form = styled.form `
display: grid;
gap: 16px;
grid-template-columns: repeat(2, 1fr);
align-items: end;
@media (max-width: 768px) {
grid-template-columns: 1fr;
}
`;
const Label = styled.label `
display: flex;
flex-direction: column;
gap: 8px;
font-weight: 600;
`;
const Input = styled.input `
padding: 12px 14px;
border-radius: 12px;
border: 1px solid rgba(126, 136, 180, 0.28);
background: rgba(16, 22, 52, 0.9);
color: #ffffff;
`;
const Checkbox = styled.input `
width: 18px;
height: 18px;
cursor: pointer;
`;
const CheckboxLabel = styled.label `
display: flex;
align-items: center;
gap: 10px;
font-weight: 600;
cursor: pointer;
`;
const Button = styled.button `
padding: 12px 16px;
border-radius: 12px;
border: none;
background: linear-gradient(135deg, #5562ff, #7d6cff);
color: #ffffff;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s ease;
&:hover {
opacity: 0.9;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
const SecondaryButton = styled.button `
padding: 10px 14px;
border-radius: 12px;
border: 1px solid rgba(126, 136, 180, 0.4);
background: rgba(16, 22, 52, 0.9);
color: #d9dcff;
font-weight: 600;
cursor: pointer;
font-size: 0.9rem;
`;
const LeaveList = styled.div `
display: flex;
flex-direction: column;
gap: 12px;
max-height: 500px;
overflow-y: auto;
`;
const LeaveItem = styled.div `
padding: 14px 16px;
border-radius: 12px;
background: ${({ $source }) => $source === "calendar" ? "rgba(85, 98, 255, 0.15)" : "rgba(126, 136, 180, 0.15)"};
border: 1px solid
${({ $source }) => $source === "calendar" ? "rgba(85, 98, 255, 0.3)" : "rgba(126, 136, 180, 0.3)"};
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
`;
const LeaveInfo = styled.div `
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
`;
const LeaveTitle = styled.span `
font-weight: 600;
`;
const LeaveDate = styled.span `
font-size: 0.9rem;
color: var(--text-muted);
`;
const LeaveSource = styled.span `
font-size: 0.85rem;
padding: 4px 10px;
border-radius: 8px;
background: rgba(85, 98, 255, 0.2);
color: #b3bbff;
`;
const StatusMessage = styled.div `
padding: 12px;
border-radius: 12px;
background: rgba(12, 18, 42, 0.7);
border: 1px solid rgba(126, 136, 180, 0.24);
color: var(--text-muted);
text-align: center;
`;
const ErrorText = styled.div `
color: #ff7b8a;
font-size: 0.9rem;
`;
const HolidayList = styled.div `
display: flex;
flex-direction: column;
gap: 12px;
max-height: 300px;
overflow-y: auto;
`;
const HolidayItem = styled.div `
padding: 12px 14px;
border-radius: 12px;
background: rgba(125, 108, 255, 0.15);
border: 1px solid rgba(125, 108, 255, 0.3);
display: flex;
flex-direction: column;
gap: 4px;
`;
const HolidayTitle = styled.span `
font-weight: 600;
font-size: 0.95rem;
`;
const HolidayDate = styled.span `
font-size: 0.85rem;
color: var(--text-muted);
`;
export const AdultDetailScreen = () => {
const { profileType, profileId } = useParams();
const navigate = useNavigate();
const [profile, setProfile] = useState(null);
const [leaves, setLeaves] = useState([]);
const [publicHolidays, setPublicHolidays] = useState([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
// Formulaire
const [title, setTitle] = useState("");
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [isAllDay, setIsAllDay] = useState(true);
const [notes, setNotes] = useState("");
useEffect(() => {
loadProfile();
loadLeaves();
loadPublicHolidays();
}, [profileId, profileType]);
const loadProfile = async () => {
if (!profileId || !profileType)
return;
try {
const endpoint = profileType === "parent" ? "parents" : "grandparents";
const response = await fetch(`/api/${endpoint}`);
const data = await response.json();
if (data.success) {
const found = data[endpoint === "parents" ? "parents" : "grandParents"]?.find((p) => p.id === profileId);
setProfile(found || null);
}
}
catch (err) {
setError("Impossible de charger le profil");
}
finally {
setLoading(false);
}
};
const loadLeaves = async () => {
if (!profileId)
return;
try {
const response = await fetch(`/api/personal-leaves?profileId=${profileId}`);
const data = await response.json();
if (data.success) {
setLeaves(data.leaves);
}
}
catch (err) {
console.error("Erreur chargement congés:", err);
}
};
const loadPublicHolidays = async () => {
try {
const response = await fetch(`/api/holidays/public?year=${new Date().getFullYear()}`);
const data = await response.json();
if (data.success) {
setPublicHolidays(data.holidays);
}
}
catch (err) {
console.error("Erreur chargement jours fériés:", err);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!profileId || !title || !startDate || !endDate) {
setError("Veuillez remplir tous les champs obligatoires");
return;
}
setSaving(true);
setError(null);
try {
const response = await fetch("/api/personal-leaves", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
profileId,
title,
startDate,
endDate,
isAllDay,
notes: notes || undefined,
source: "manual"
})
});
const data = await response.json();
if (data.success) {
setTitle("");
setStartDate("");
setEndDate("");
setIsAllDay(true);
setNotes("");
loadLeaves();
}
else {
setError("Impossible d'ajouter le congé");
}
}
catch (err) {
setError("Erreur de connexion au serveur");
}
finally {
setSaving(false);
}
};
const handleDelete = async (leaveId) => {
if (!window.confirm("Supprimer ce congé ?"))
return;
try {
await fetch(`/api/personal-leaves/${leaveId}`, { method: "DELETE" });
loadLeaves();
}
catch (err) {
alert("Impossible de supprimer le congé");
}
};
const formatDate = (dateStr) => {
return new Date(dateStr).toLocaleDateString("fr-FR", {
day: "numeric",
month: "long",
year: "numeric"
});
};
if (loading) {
return (_jsx(Container, { children: _jsx(StatusMessage, { children: "Chargement du profil..." }) }));
}
if (!profile) {
return (_jsx(Container, { children: _jsx(StatusMessage, { children: "Profil introuvable" }) }));
}
const initials = profile.fullName
.split(" ")
.filter(Boolean)
.map((part) => part.charAt(0))
.join("")
.slice(0, 2)
.toUpperCase();
return (_jsxs(Container, { children: [_jsxs(Header, { children: [_jsx(BackButton, { onClick: () => navigate("/profiles"), children: "\u2190 Retour" }), _jsx(Avatar, { "$color": profile.colorHex, "$imageUrl": profile.avatar?.url, children: !profile.avatar?.url && initials }), _jsxs(HeaderInfo, { children: [_jsx(Title, { children: profile.fullName }), profile.email && _jsx(Meta, { children: profile.email }), profile.notes && _jsx(Meta, { children: profile.notes })] })] }), _jsxs(Content, { children: [_jsxs(Card, { children: [_jsx(CardTitle, { children: "Ajouter un cong\u00E9 personnel" }), _jsxs(Form, { onSubmit: handleSubmit, children: [_jsxs(Label, { style: { gridColumn: "1 / -1" }, children: ["Titre du cong\u00E9 *", _jsx(Input, { type: "text", value: title, onChange: (e) => setTitle(e.target.value), placeholder: "Ex: Cong\u00E9s d'\u00E9t\u00E9, RTT, Repos...", required: true })] }), _jsxs(Label, { children: ["Date de d\u00E9but *", _jsx(Input, { type: "date", value: startDate, onChange: (e) => setStartDate(e.target.value), required: true })] }), _jsxs(Label, { children: ["Date de fin *", _jsx(Input, { type: "date", value: endDate, onChange: (e) => setEndDate(e.target.value), required: true })] }), _jsxs(Label, { style: { gridColumn: "1 / -1" }, children: ["Notes (optionnel)", _jsx(Input, { type: "text", value: notes, onChange: (e) => setNotes(e.target.value), placeholder: "Informations compl\u00E9mentaires..." })] }), _jsxs(CheckboxLabel, { style: { gridColumn: "1 / -1" }, children: [_jsx(Checkbox, { type: "checkbox", checked: isAllDay, onChange: (e) => setIsAllDay(e.target.checked) }), "Journ\u00E9e compl\u00E8te"] }), _jsx(Button, { type: "submit", disabled: saving, style: { gridColumn: "1 / -1" }, children: saving ? "Enregistrement..." : "Ajouter le congé" })] }), error && _jsx(ErrorText, { children: error }), _jsx(Meta, { children: "Les cong\u00E9s personnels seront automatiquement affich\u00E9s dans les plannings mensuels et quotidiens. Vous pouvez \u00E9galement synchroniser vos cong\u00E9s depuis votre agenda (Google, Outlook) via l'onglet \"Int\u00E9grations\"." })] }), _jsxs(Card, { children: [_jsxs(CardTitle, { children: ["Mes cong\u00E9s personnels (", leaves.length, ")"] }), leaves.length === 0 ? (_jsx(StatusMessage, { children: "Aucun cong\u00E9 enregistr\u00E9 pour le moment" })) : (_jsx(LeaveList, { children: leaves.map((leave) => (_jsxs(LeaveItem, { "$source": leave.source ?? "manual", children: [_jsxs(LeaveInfo, { children: [_jsx(LeaveTitle, { children: leave.title }), _jsx(LeaveDate, { children: leave.startDate === leave.endDate
? formatDate(leave.startDate)
: `Du ${formatDate(leave.startDate)} au ${formatDate(leave.endDate)}` }), leave.notes && _jsx(LeaveDate, { children: leave.notes })] }), _jsx(LeaveSource, { children: leave.source === "calendar" ? "Agenda" : "Manuel" }), leave.source === "manual" && (_jsx(SecondaryButton, { onClick: () => handleDelete(leave.id), children: "Supprimer" }))] }, leave.id))) }))] }), _jsxs(Card, { children: [_jsxs(CardTitle, { children: ["Jours f\u00E9ri\u00E9s en France (", publicHolidays.length, ")"] }), publicHolidays.length === 0 ? (_jsx(StatusMessage, { children: "Aucun jour f\u00E9ri\u00E9 trouv\u00E9" })) : (_jsx(HolidayList, { children: publicHolidays.map((holiday) => (_jsxs(HolidayItem, { children: [_jsx(HolidayTitle, { children: holiday.title }), _jsx(HolidayDate, { children: formatDate(holiday.startDate) })] }, holiday.id))) }))] })] })] }));
};

View File

@@ -0,0 +1,544 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import styled from "styled-components";
import { PersonalLeave, Holiday } from "@family-planner/types";
type AdultProfile = {
id: string;
fullName: string;
colorHex: string;
email?: string;
notes?: string;
avatar?: { kind: "preset" | "custom"; url: string; name?: string };
};
const Container = styled.div`
max-width: 1200px;
margin: 0 auto;
`;
const Header = styled.header`
display: flex;
align-items: center;
gap: 24px;
margin-bottom: 32px;
padding: 24px;
border-radius: 18px;
background: rgba(29, 36, 66, 0.92);
border: 1px solid rgba(126, 136, 180, 0.22);
`;
const BackButton = styled.button`
padding: 10px 16px;
border-radius: 12px;
border: 1px solid rgba(126, 136, 180, 0.4);
background: rgba(16, 22, 52, 0.9);
color: #d9dcff;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s ease;
&:hover {
transform: translateY(-1px);
}
`;
const Avatar = styled.div<{ $color: string; $imageUrl?: string }>`
width: 96px;
height: 96px;
border-radius: 24px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 2rem;
background: ${({ $imageUrl, $color }) =>
$imageUrl ? `url(${$imageUrl}) center/cover` : `rgba(9, 13, 28, 0.8)`};
color: ${({ $imageUrl }) => ($imageUrl ? "transparent" : "#f4f5ff")};
border: 3px solid ${({ $color }) => $color};
`;
const HeaderInfo = styled.div`
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
`;
const Title = styled.h1`
margin: 0;
font-size: 2rem;
`;
const Meta = styled.p`
margin: 0;
color: var(--text-muted);
`;
const Content = styled.div`
display: grid;
gap: 24px;
`;
const Card = styled.section`
padding: 24px;
border-radius: 18px;
background: rgba(29, 36, 66, 0.92);
border: 1px solid rgba(126, 136, 180, 0.22);
display: flex;
flex-direction: column;
gap: 20px;
`;
const CardTitle = styled.h2`
margin: 0;
font-size: 1.4rem;
display: flex;
align-items: center;
justify-content: space-between;
`;
const Form = styled.form`
display: grid;
gap: 16px;
grid-template-columns: repeat(2, 1fr);
align-items: end;
@media (max-width: 768px) {
grid-template-columns: 1fr;
}
`;
const Label = styled.label`
display: flex;
flex-direction: column;
gap: 8px;
font-weight: 600;
`;
const Input = styled.input`
padding: 12px 14px;
border-radius: 12px;
border: 1px solid rgba(126, 136, 180, 0.28);
background: rgba(16, 22, 52, 0.9);
color: #ffffff;
`;
const Checkbox = styled.input`
width: 18px;
height: 18px;
cursor: pointer;
`;
const CheckboxLabel = styled.label`
display: flex;
align-items: center;
gap: 10px;
font-weight: 600;
cursor: pointer;
`;
const Button = styled.button`
padding: 12px 16px;
border-radius: 12px;
border: none;
background: linear-gradient(135deg, #5562ff, #7d6cff);
color: #ffffff;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s ease;
&:hover {
opacity: 0.9;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
const SecondaryButton = styled.button`
padding: 10px 14px;
border-radius: 12px;
border: 1px solid rgba(126, 136, 180, 0.4);
background: rgba(16, 22, 52, 0.9);
color: #d9dcff;
font-weight: 600;
cursor: pointer;
font-size: 0.9rem;
`;
const LeaveList = styled.div`
display: flex;
flex-direction: column;
gap: 12px;
max-height: 500px;
overflow-y: auto;
`;
const LeaveItem = styled.div<{ $source: "manual" | "calendar" }>`
padding: 14px 16px;
border-radius: 12px;
background: ${({ $source }) =>
$source === "calendar" ? "rgba(85, 98, 255, 0.15)" : "rgba(126, 136, 180, 0.15)"};
border: 1px solid
${({ $source }) =>
$source === "calendar" ? "rgba(85, 98, 255, 0.3)" : "rgba(126, 136, 180, 0.3)"};
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
`;
const LeaveInfo = styled.div`
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
`;
const LeaveTitle = styled.span`
font-weight: 600;
`;
const LeaveDate = styled.span`
font-size: 0.9rem;
color: var(--text-muted);
`;
const LeaveSource = styled.span`
font-size: 0.85rem;
padding: 4px 10px;
border-radius: 8px;
background: rgba(85, 98, 255, 0.2);
color: #b3bbff;
`;
const StatusMessage = styled.div`
padding: 12px;
border-radius: 12px;
background: rgba(12, 18, 42, 0.7);
border: 1px solid rgba(126, 136, 180, 0.24);
color: var(--text-muted);
text-align: center;
`;
const ErrorText = styled.div`
color: #ff7b8a;
font-size: 0.9rem;
`;
const HolidayList = styled.div`
display: flex;
flex-direction: column;
gap: 12px;
max-height: 300px;
overflow-y: auto;
`;
const HolidayItem = styled.div`
padding: 12px 14px;
border-radius: 12px;
background: rgba(125, 108, 255, 0.15);
border: 1px solid rgba(125, 108, 255, 0.3);
display: flex;
flex-direction: column;
gap: 4px;
`;
const HolidayTitle = styled.span`
font-weight: 600;
font-size: 0.95rem;
`;
const HolidayDate = styled.span`
font-size: 0.85rem;
color: var(--text-muted);
`;
export const AdultDetailScreen = () => {
const { profileType, profileId } = useParams<{ profileType: string; profileId: string }>();
const navigate = useNavigate();
const [profile, setProfile] = useState<AdultProfile | null>(null);
const [leaves, setLeaves] = useState<PersonalLeave[]>([]);
const [publicHolidays, setPublicHolidays] = useState<Holiday[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Formulaire
const [title, setTitle] = useState("");
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [isAllDay, setIsAllDay] = useState(true);
const [notes, setNotes] = useState("");
useEffect(() => {
loadProfile();
loadLeaves();
loadPublicHolidays();
}, [profileId, profileType]);
const loadProfile = async () => {
if (!profileId || !profileType) return;
try {
const endpoint = profileType === "parent" ? "parents" : "grandparents";
const response = await fetch(`/api/${endpoint}`);
const data = await response.json();
if (data.success) {
const found = data[endpoint === "parents" ? "parents" : "grandParents"]?.find(
(p: AdultProfile) => p.id === profileId
);
setProfile(found || null);
}
} catch (err) {
setError("Impossible de charger le profil");
} finally {
setLoading(false);
}
};
const loadLeaves = async () => {
if (!profileId) return;
try {
const response = await fetch(`/api/personal-leaves?profileId=${profileId}`);
const data = await response.json();
if (data.success) {
setLeaves(data.leaves);
}
} catch (err) {
console.error("Erreur chargement congés:", err);
}
};
const loadPublicHolidays = async () => {
try {
const response = await fetch(`/api/holidays/public?year=${new Date().getFullYear()}`);
const data = await response.json();
if (data.success) {
setPublicHolidays(data.holidays);
}
} catch (err) {
console.error("Erreur chargement jours fériés:", err);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!profileId || !title || !startDate || !endDate) {
setError("Veuillez remplir tous les champs obligatoires");
return;
}
setSaving(true);
setError(null);
try {
const response = await fetch("/api/personal-leaves", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
profileId,
title,
startDate,
endDate,
isAllDay,
notes: notes || undefined,
source: "manual"
})
});
const data = await response.json();
if (data.success) {
setTitle("");
setStartDate("");
setEndDate("");
setIsAllDay(true);
setNotes("");
loadLeaves();
} else {
setError("Impossible d'ajouter le congé");
}
} catch (err) {
setError("Erreur de connexion au serveur");
} finally {
setSaving(false);
}
};
const handleDelete = async (leaveId: string) => {
if (!window.confirm("Supprimer ce congé ?")) return;
try {
await fetch(`/api/personal-leaves/${leaveId}`, { method: "DELETE" });
loadLeaves();
} catch (err) {
alert("Impossible de supprimer le congé");
}
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString("fr-FR", {
day: "numeric",
month: "long",
year: "numeric"
});
};
if (loading) {
return (
<Container>
<StatusMessage>Chargement du profil...</StatusMessage>
</Container>
);
}
if (!profile) {
return (
<Container>
<StatusMessage>Profil introuvable</StatusMessage>
</Container>
);
}
const initials = profile.fullName
.split(" ")
.filter(Boolean)
.map((part) => part.charAt(0))
.join("")
.slice(0, 2)
.toUpperCase();
return (
<Container>
<Header>
<BackButton onClick={() => navigate("/profiles")}> Retour</BackButton>
<Avatar $color={profile.colorHex} $imageUrl={profile.avatar?.url}>
{!profile.avatar?.url && initials}
</Avatar>
<HeaderInfo>
<Title>{profile.fullName}</Title>
{profile.email && <Meta>{profile.email}</Meta>}
{profile.notes && <Meta>{profile.notes}</Meta>}
</HeaderInfo>
</Header>
<Content>
<Card>
<CardTitle>Ajouter un congé personnel</CardTitle>
<Form onSubmit={handleSubmit}>
<Label style={{ gridColumn: "1 / -1" }}>
Titre du congé *
<Input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Ex: Congés d'été, RTT, Repos..."
required
/>
</Label>
<Label>
Date de début *
<Input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
required
/>
</Label>
<Label>
Date de fin *
<Input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
required
/>
</Label>
<Label style={{ gridColumn: "1 / -1" }}>
Notes (optionnel)
<Input
type="text"
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Informations complémentaires..."
/>
</Label>
<CheckboxLabel style={{ gridColumn: "1 / -1" }}>
<Checkbox
type="checkbox"
checked={isAllDay}
onChange={(e) => setIsAllDay(e.target.checked)}
/>
Journée complète
</CheckboxLabel>
<Button type="submit" disabled={saving} style={{ gridColumn: "1 / -1" }}>
{saving ? "Enregistrement..." : "Ajouter le congé"}
</Button>
</Form>
{error && <ErrorText>{error}</ErrorText>}
<Meta>
Les congés personnels seront automatiquement affichés dans les plannings mensuels et
quotidiens. Vous pouvez également synchroniser vos congés depuis votre agenda (Google,
Outlook) via l'onglet "Intégrations".
</Meta>
</Card>
<Card>
<CardTitle>Mes congés personnels ({leaves.length})</CardTitle>
{leaves.length === 0 ? (
<StatusMessage>Aucun congé enregistré pour le moment</StatusMessage>
) : (
<LeaveList>
{leaves.map((leave) => (
<LeaveItem key={leave.id} $source={leave.source ?? "manual"}>
<LeaveInfo>
<LeaveTitle>{leave.title}</LeaveTitle>
<LeaveDate>
{leave.startDate === leave.endDate
? formatDate(leave.startDate)
: `Du ${formatDate(leave.startDate)} au ${formatDate(leave.endDate)}`}
</LeaveDate>
{leave.notes && <LeaveDate>{leave.notes}</LeaveDate>}
</LeaveInfo>
<LeaveSource>
{leave.source === "calendar" ? "Agenda" : "Manuel"}
</LeaveSource>
{leave.source === "manual" && (
<SecondaryButton onClick={() => handleDelete(leave.id)}>
Supprimer
</SecondaryButton>
)}
</LeaveItem>
))}
</LeaveList>
)}
</Card>
<Card>
<CardTitle>Jours fériés en France ({publicHolidays.length})</CardTitle>
{publicHolidays.length === 0 ? (
<StatusMessage>Aucun jour férié trouvé</StatusMessage>
) : (
<HolidayList>
{publicHolidays.map((holiday) => (
<HolidayItem key={holiday.id}>
<HolidayTitle>{holiday.title}</HolidayTitle>
<HolidayDate>{formatDate(holiday.startDate)}</HolidayDate>
</HolidayItem>
))}
</HolidayList>
)}
</Card>
</Content>
</Container>
);
};

View File

@@ -0,0 +1,151 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useEffect, useMemo, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import styled from "styled-components";
import { completeCalendarOAuth } from "../services/api-client";
import { consumePendingOAuthState, peekPendingOAuthState, storeOAuthResult } from "../utils/calendar-oauth";
const Container = styled.main `
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(circle at top, rgba(32, 45, 112, 0.55), rgba(9, 13, 28, 0.95));
color: #f4f5ff;
padding: 32px;
`;
const Card = styled.div `
max-width: 520px;
width: 100%;
border-radius: 20px;
border: 1px solid rgba(126, 136, 180, 0.3);
background: rgba(16, 22, 52, 0.88);
padding: 32px;
display: flex;
flex-direction: column;
gap: 18px;
box-shadow: 0 32px 68px rgba(0, 0, 0, 0.45);
`;
const Title = styled.h1 `
margin: 0;
font-size: 1.6rem;
`;
const Message = styled.p `
margin: 0;
line-height: 1.6;
color: rgba(197, 202, 240, 0.85);
`;
const Details = styled.pre `
margin: 0;
padding: 12px;
border-radius: 12px;
background: rgba(9, 13, 28, 0.75);
border: 1px solid rgba(126, 136, 180, 0.25);
font-size: 0.85rem;
color: rgba(197, 202, 240, 0.75);
white-space: pre-wrap;
word-break: break-word;
`;
const Button = styled.button `
align-self: flex-start;
padding: 10px 18px;
border-radius: 12px;
border: 1px solid rgba(85, 98, 255, 0.5);
background: rgba(85, 98, 255, 0.25);
color: #f4f5ff;
font-weight: 600;
cursor: pointer;
transition: background 0.2s ease, transform 0.15s ease;
&:hover {
background: rgba(85, 98, 255, 0.35);
transform: translateY(-1px);
}
`;
const providerLabel = (provider) => (provider === "google" ? "Google" : "Outlook");
export const CalendarOAuthCallbackScreen = () => {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [status, setStatus] = useState("processing");
const [statusMessage, setStatusMessage] = useState("Initialisation de la connexion securisee...");
const [details, setDetails] = useState({});
const stateParam = searchParams.get("state") ?? "";
const codeParam = searchParams.get("code") ?? undefined;
const errorParam = searchParams.get("error") ?? undefined;
const providerParam = (searchParams.get("provider") ?? undefined);
const pendingInfo = useMemo(() => (stateParam ? peekPendingOAuthState(stateParam) : null), [stateParam]);
useEffect(() => {
const execute = async () => {
if (!stateParam) {
setStatus("error");
setStatusMessage("Parametre 'state' manquant. Impossible de finaliser la connexion.");
return;
}
const inferredProvider = providerParam ?? pendingInfo?.provider ?? "google";
const payload = {
provider: inferredProvider,
state: stateParam,
code: codeParam,
error: errorParam,
profileId: pendingInfo?.profileId
};
try {
const response = await completeCalendarOAuth(payload);
storeOAuthResult({ success: response?.success, provider: inferredProvider, state: stateParam });
if (window.opener) {
window.opener.postMessage({
type: "fp-calendar-oauth",
success: response?.success ?? !errorParam,
state: stateParam,
provider: inferredProvider,
email: response?.email,
label: response?.label
}, window.location.origin);
}
setDetails({
provider: providerLabel(inferredProvider),
state: stateParam,
profileId: pendingInfo?.profileId,
result: response
});
if (response?.success && !errorParam) {
setStatus("success");
setStatusMessage("Connexion validee ! Vous pouvez revenir sur Family Planner.");
}
else {
setStatus("error");
setStatusMessage("La connexion n'a pas pu etre finalisee. Merci de reessayer.");
}
}
catch (error) {
setStatus("error");
setStatusMessage("Une erreur est survenue pendant la finalisation de l'autorisation.");
setDetails({
provider: providerLabel(inferredProvider),
state: stateParam,
error: String(error)
});
if (window.opener) {
window.opener.postMessage({
type: "fp-calendar-oauth",
success: false,
state: stateParam,
provider: inferredProvider,
error: String(error)
}, window.location.origin);
}
}
finally {
if (!window.opener) {
consumePendingOAuthState(stateParam);
}
setTimeout(() => window.close(), 2500);
}
};
void execute();
}, [codeParam, errorParam, pendingInfo, providerParam, stateParam]);
return (_jsx(Container, { children: _jsxs(Card, { children: [_jsx(Title, { children: status === "processing"
? "Connexion a l'agenda..."
: status === "success"
? "Agenda connecte !"
: "Connexion interrompue" }), _jsx(Message, { children: statusMessage }), Object.keys(details).length > 0 ? _jsx(Details, { children: JSON.stringify(details, null, 2) }) : null, status !== "processing" ? (_jsx(Button, { type: "button", onClick: () => navigate("/", { replace: true }), children: "Retourner au tableau de bord" })) : null] }) }));
};

View File

@@ -0,0 +1,189 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import styled from "styled-components";
import { completeCalendarOAuth } from "../services/api-client";
import { CalendarProvider } from "../types/calendar";
import { consumePendingOAuthState, peekPendingOAuthState, storeOAuthResult } from "../utils/calendar-oauth";
const Container = styled.main`
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(circle at top, rgba(32, 45, 112, 0.55), rgba(9, 13, 28, 0.95));
color: #f4f5ff;
padding: 32px;
`;
const Card = styled.div`
max-width: 520px;
width: 100%;
border-radius: 20px;
border: 1px solid rgba(126, 136, 180, 0.3);
background: rgba(16, 22, 52, 0.88);
padding: 32px;
display: flex;
flex-direction: column;
gap: 18px;
box-shadow: 0 32px 68px rgba(0, 0, 0, 0.45);
`;
const Title = styled.h1`
margin: 0;
font-size: 1.6rem;
`;
const Message = styled.p`
margin: 0;
line-height: 1.6;
color: rgba(197, 202, 240, 0.85);
`;
const Details = styled.pre`
margin: 0;
padding: 12px;
border-radius: 12px;
background: rgba(9, 13, 28, 0.75);
border: 1px solid rgba(126, 136, 180, 0.25);
font-size: 0.85rem;
color: rgba(197, 202, 240, 0.75);
white-space: pre-wrap;
word-break: break-word;
`;
const Button = styled.button`
align-self: flex-start;
padding: 10px 18px;
border-radius: 12px;
border: 1px solid rgba(85, 98, 255, 0.5);
background: rgba(85, 98, 255, 0.25);
color: #f4f5ff;
font-weight: 600;
cursor: pointer;
transition: background 0.2s ease, transform 0.15s ease;
&:hover {
background: rgba(85, 98, 255, 0.35);
transform: translateY(-1px);
}
`;
type CallbackStatus = "processing" | "success" | "error";
const providerLabel = (provider: CalendarProvider) => (provider === "google" ? "Google" : "Outlook");
export const CalendarOAuthCallbackScreen = () => {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [status, setStatus] = useState<CallbackStatus>("processing");
const [statusMessage, setStatusMessage] = useState<string>("Initialisation de la connexion securisee...");
const [details, setDetails] = useState<Record<string, unknown>>({});
const stateParam = searchParams.get("state") ?? "";
const codeParam = searchParams.get("code") ?? undefined;
const errorParam = searchParams.get("error") ?? undefined;
const providerParam = (searchParams.get("provider") ?? undefined) as CalendarProvider | undefined;
const pendingInfo = useMemo(() => (stateParam ? peekPendingOAuthState(stateParam) : null), [stateParam]);
useEffect(() => {
const execute = async () => {
if (!stateParam) {
setStatus("error");
setStatusMessage("Parametre 'state' manquant. Impossible de finaliser la connexion.");
return;
}
const inferredProvider = providerParam ?? pendingInfo?.provider ?? "google";
const payload = {
provider: inferredProvider,
state: stateParam,
code: codeParam,
error: errorParam,
profileId: pendingInfo?.profileId
};
try {
const response = await completeCalendarOAuth(payload);
storeOAuthResult({ success: response?.success, provider: inferredProvider, state: stateParam });
if (window.opener) {
window.opener.postMessage(
{
type: "fp-calendar-oauth",
success: response?.success ?? !errorParam,
state: stateParam,
provider: inferredProvider,
email: response?.email,
label: response?.label
},
window.location.origin
);
}
setDetails({
provider: providerLabel(inferredProvider),
state: stateParam,
profileId: pendingInfo?.profileId,
result: response
});
if (response?.success && !errorParam) {
setStatus("success");
setStatusMessage("Connexion validee ! Vous pouvez revenir sur Family Planner.");
} else {
setStatus("error");
setStatusMessage("La connexion n'a pas pu etre finalisee. Merci de reessayer.");
}
} catch (error) {
setStatus("error");
setStatusMessage("Une erreur est survenue pendant la finalisation de l'autorisation.");
setDetails({
provider: providerLabel(inferredProvider),
state: stateParam,
error: String(error)
});
if (window.opener) {
window.opener.postMessage(
{
type: "fp-calendar-oauth",
success: false,
state: stateParam,
provider: inferredProvider,
error: String(error)
},
window.location.origin
);
}
} finally {
if (!window.opener) {
consumePendingOAuthState(stateParam);
}
setTimeout(() => window.close(), 2500);
}
};
void execute();
}, [codeParam, errorParam, pendingInfo, providerParam, stateParam]);
return (
<Container>
<Card>
<Title>
{status === "processing"
? "Connexion a l'agenda..."
: status === "success"
? "Agenda connecte !"
: "Connexion interrompue"}
</Title>
<Message>{statusMessage}</Message>
{Object.keys(details).length > 0 ? <Details>{JSON.stringify(details, null, 2)}</Details> : null}
{status !== "processing" ? (
<Button type="button" onClick={() => navigate("/", { replace: true })}>
Retourner au tableau de bord
</Button>
) : null}
</Card>
</Container>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,142 @@
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
import { useEffect, useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import styled from "styled-components";
import { useChildren } from "../state/ChildrenContext";
import { apiClient } from "../services/api-client";
import { TimeGridMulti } from "../components/TimeGridMulti";
const Container = styled.div `
display: flex;
flex-direction: column;
gap: 16px;
`;
const Title = styled.h1 `
margin: 0;
font-size: 1.6rem;
`;
const Toggle = styled.div `
display: inline-flex;
gap: 8px;
`;
const ToggleBtn = styled.button `
padding: 8px 12px;
border-radius: 10px;
border: 1px solid rgba(126, 136, 180, 0.25);
background: ${({ $active }) => ($active ? "rgba(85,98,255,0.2)" : "transparent")};
color: #e9ebff;
cursor: pointer;
`;
export const ChildPlanningScreen = () => {
const { childId } = useParams();
const { children } = useChildren();
const [mode, setMode] = useState("week");
const [dayEvents, setDayEvents] = useState([]);
const [weekDays, setWeekDays] = useState({});
const [timeZone, setTimeZone] = useState(null);
const [lastSchedule, setLastSchedule] = useState(null);
const child = children.find((c) => c.id === childId);
const color = child?.colorHex ?? "#5562ff";
const today = useMemo(() => new Date(), []);
useEffect(() => {
const saved = localStorage.getItem("fp:view:timeZone");
if (!saved || saved === "auto") {
setTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone);
}
else {
setTimeZone(saved);
}
}, []);
useEffect(() => {
const loadLast = async () => {
const items = await apiClient.get("/schedules");
const forChild = items.filter((i) => i.childId === childId);
forChild.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
if (forChild.length > 0) {
setLastSchedule({ sourceFileUrl: forChild[0].sourceFileUrl, exportCsvUrl: forChild[0].exportCsvUrl });
// Reload planning data when we detect a new schedule
void loadPlanningData();
}
};
if (childId)
void loadLast();
}, [childId]);
// Reload last schedule periodically to detect new uploads
useEffect(() => {
if (!childId)
return;
const interval = setInterval(async () => {
const items = await apiClient.get("/schedules");
const forChild = items.filter((i) => i.childId === childId);
forChild.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
if (forChild.length > 0) {
const newSchedule = { sourceFileUrl: forChild[0].sourceFileUrl, exportCsvUrl: forChild[0].exportCsvUrl };
// Only reload if schedule changed
if (newSchedule.sourceFileUrl !== lastSchedule?.sourceFileUrl) {
setLastSchedule(newSchedule);
void loadPlanningData();
}
}
}, 5000); // Check every 5 seconds
return () => clearInterval(interval);
}, [childId, lastSchedule]);
// Load planning data
const loadPlanningData = async () => {
if (!childId)
return;
const y = today.getFullYear();
const m = String(today.getMonth() + 1).padStart(2, "0");
const d = String(today.getDate()).padStart(2, "0");
const date = `${y}-${m}-${d}`; // local date
const day = await apiClient.get(`/schedules/day/activities?date=${date}&childId=${childId}`);
setDayEvents(day.items?.[0]?.activities ?? []);
const start = new Date(today);
const weekday = (start.getDay() + 6) % 7; // 0=Mon
start.setDate(start.getDate() - weekday);
const sy = start.getFullYear();
const sm = String(start.getMonth() + 1).padStart(2, "0");
const sd = String(start.getDate()).padStart(2, "0");
const startISO = `${sy}-${sm}-${sd}`;
const week = await apiClient.get(`/schedules/week/activities?start=${startISO}&childId=${childId}`);
setWeekDays(week.items?.[0]?.days ?? {});
};
useEffect(() => {
void loadPlanningData();
}, [childId, today]);
// Auto-reload when user returns to the tab
useEffect(() => {
const handleVisibilityChange = () => {
if (!document.hidden) {
void loadPlanningData();
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => document.removeEventListener("visibilitychange", handleVisibilityChange);
}, [childId, today]);
const dayColumns = useMemo(() => {
return [
{
id: childId || "child",
title: child?.fullName ?? "Enfant",
color,
events: dayEvents,
dateISO: today.toISOString().slice(0, 10)
}
];
}, [childId, child?.fullName, color, dayEvents]);
const weekColumns = useMemo(() => {
// Build 7 days columns Mon..Sun
const start = new Date(today);
const weekday = (start.getDay() + 6) % 7; // 0=Mon
start.setDate(start.getDate() - weekday);
const cols = [];
for (let i = 0; i < 7; i++) {
const d = new Date(start);
d.setDate(start.getDate() + i);
const dISO = d.toISOString().slice(0, 10);
const label = d.toLocaleDateString("fr-FR", { weekday: "short", day: "2-digit", month: "2-digit" });
cols.push({ id: dISO, title: label, color, events: weekDays[dISO] ?? [], dateISO: dISO });
}
return cols;
}, [today, weekDays, color]);
return (_jsxs(Container, { children: [_jsxs(Title, { children: ["Planning \u2014 ", child?.fullName ?? "Enfant"] }), _jsxs(Toggle, { children: [_jsx(ToggleBtn, { "$active": mode === "day", onClick: () => setMode("day"), children: "Jour" }), _jsx(ToggleBtn, { "$active": mode === "week", onClick: () => setMode("week"), children: "Semaine" })] }), _jsxs("div", { style: { display: "flex", gap: 12, flexWrap: "wrap" }, children: [_jsx("a", { href: lastSchedule?.sourceFileUrl, target: "_blank", rel: "noreferrer", style: { pointerEvents: lastSchedule?.sourceFileUrl ? "auto" : "none", opacity: lastSchedule?.sourceFileUrl ? 1 : 0.6 }, children: "Voir le fichier brut" }), _jsx("a", { href: lastSchedule?.exportCsvUrl, target: "_blank", rel: "noreferrer", style: { pointerEvents: lastSchedule?.exportCsvUrl ? "auto" : "none", opacity: lastSchedule?.exportCsvUrl ? 1 : 0.6 }, children: "T\u00E9l\u00E9charger analyse" })] }), mode === "day" ? (_jsx(TimeGridMulti, { columns: dayColumns, timeZone: timeZone ?? undefined, showNowLine: true, now: new Date() })) : (_jsx(TimeGridMulti, { columns: weekColumns, timeZone: timeZone ?? undefined, showNowLine: true, now: new Date() }))] }));
};

View File

@@ -0,0 +1,188 @@
import { useEffect, useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import styled from "styled-components";
import { useChildren } from "../state/ChildrenContext";
import { apiClient } from "../services/api-client";
import { TimeGridMulti } from "../components/TimeGridMulti";
import { Link } from "react-router-dom";
const Container = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
`;
const Title = styled.h1`
margin: 0;
font-size: 1.6rem;
`;
const Toggle = styled.div`
display: inline-flex;
gap: 8px;
`;
const ToggleBtn = styled.button<{ $active?: boolean }>`
padding: 8px 12px;
border-radius: 10px;
border: 1px solid rgba(126, 136, 180, 0.25);
background: ${({ $active }) => ($active ? "rgba(85,98,255,0.2)" : "transparent")};
color: #e9ebff;
cursor: pointer;
`;
type Act = { title: string; startDateTime: string; endDateTime: string; notes?: string };
export const ChildPlanningScreen = () => {
const { childId } = useParams();
const { children } = useChildren();
const [mode, setMode] = useState<"day" | "week">("week");
const [dayEvents, setDayEvents] = useState<Act[]>([]);
const [weekDays, setWeekDays] = useState<Record<string, Act[]>>({});
const [timeZone, setTimeZone] = useState<string | null>(null);
const [lastSchedule, setLastSchedule] = useState<{ sourceFileUrl?: string; exportCsvUrl?: string } | null>(null);
const child = children.find((c) => c.id === childId);
const color = child?.colorHex ?? "#5562ff";
const today = useMemo(() => new Date(), []);
useEffect(() => {
const saved = localStorage.getItem("fp:view:timeZone");
if (!saved || saved === "auto") {
setTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone);
} else {
setTimeZone(saved);
}
}, []);
useEffect(() => {
const loadLast = async () => {
const items = await apiClient.get<Array<{ id: string; childId: string; sourceFileUrl?: string; exportCsvUrl?: string; createdAt: string }>>("/schedules");
const forChild = items.filter((i) => i.childId === childId);
forChild.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
if (forChild.length > 0) {
setLastSchedule({ sourceFileUrl: forChild[0].sourceFileUrl, exportCsvUrl: (forChild[0] as any).exportCsvUrl });
// Reload planning data when we detect a new schedule
void loadPlanningData();
}
};
if (childId) void loadLast();
}, [childId]);
// Reload last schedule periodically to detect new uploads
useEffect(() => {
if (!childId) return;
const interval = setInterval(async () => {
const items = await apiClient.get<Array<{ id: string; childId: string; sourceFileUrl?: string; exportCsvUrl?: string; createdAt: string }>>("/schedules");
const forChild = items.filter((i) => i.childId === childId);
forChild.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
if (forChild.length > 0) {
const newSchedule = { sourceFileUrl: forChild[0].sourceFileUrl, exportCsvUrl: (forChild[0] as any).exportCsvUrl };
// Only reload if schedule changed
if (newSchedule.sourceFileUrl !== lastSchedule?.sourceFileUrl) {
setLastSchedule(newSchedule);
void loadPlanningData();
}
}
}, 5000); // Check every 5 seconds
return () => clearInterval(interval);
}, [childId, lastSchedule]);
// Load planning data
const loadPlanningData = async () => {
if (!childId) return;
const y = today.getFullYear();
const m = String(today.getMonth() + 1).padStart(2, "0");
const d = String(today.getDate()).padStart(2, "0");
const date = `${y}-${m}-${d}`; // local date
const day = await apiClient.get<{ date: string; items: Array<{ childId: string; activities: Act[] }> }>(`/schedules/day/activities?date=${date}&childId=${childId}`);
setDayEvents(day.items?.[0]?.activities ?? []);
const start = new Date(today);
const weekday = (start.getDay() + 6) % 7; // 0=Mon
start.setDate(start.getDate() - weekday);
const sy = start.getFullYear();
const sm = String(start.getMonth() + 1).padStart(2, "0");
const sd = String(start.getDate()).padStart(2, "0");
const startISO = `${sy}-${sm}-${sd}`;
const week = await apiClient.get<{ start: string; items: Array<{ childId: string; days: Record<string, Act[]> }> }>(`/schedules/week/activities?start=${startISO}&childId=${childId}`);
setWeekDays(week.items?.[0]?.days ?? {});
};
useEffect(() => {
void loadPlanningData();
}, [childId, today]);
// Auto-reload when user returns to the tab
useEffect(() => {
const handleVisibilityChange = () => {
if (!document.hidden) {
void loadPlanningData();
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => document.removeEventListener("visibilitychange", handleVisibilityChange);
}, [childId, today]);
const dayColumns = useMemo(() => {
return [
{
id: childId || "child",
title: child?.fullName ?? "Enfant",
color,
events: dayEvents,
dateISO: today.toISOString().slice(0, 10)
}
];
}, [childId, child?.fullName, color, dayEvents]);
const weekColumns = useMemo(() => {
// Build 7 days columns Mon..Sun
const start = new Date(today);
const weekday = (start.getDay() + 6) % 7; // 0=Mon
start.setDate(start.getDate() - weekday);
const cols = [] as { id: string; title: string; color: string; events: Act[]; dateISO?: string }[];
for (let i = 0; i < 7; i++) {
const d = new Date(start);
d.setDate(start.getDate() + i);
const dISO = d.toISOString().slice(0, 10);
const label = d.toLocaleDateString("fr-FR", { weekday: "short", day: "2-digit", month: "2-digit" });
cols.push({ id: dISO, title: label, color, events: weekDays[dISO] ?? [], dateISO: dISO });
}
return cols;
}, [today, weekDays, color]);
return (
<Container>
<Title>Planning {child?.fullName ?? "Enfant"}</Title>
<Toggle>
<ToggleBtn $active={mode === "day"} onClick={() => setMode("day")}>Jour</ToggleBtn>
<ToggleBtn $active={mode === "week"} onClick={() => setMode("week")}>Semaine</ToggleBtn>
</Toggle>
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
<a
href={lastSchedule?.sourceFileUrl}
target="_blank"
rel="noreferrer"
style={{ pointerEvents: lastSchedule?.sourceFileUrl ? "auto" : "none", opacity: lastSchedule?.sourceFileUrl ? 1 : 0.6 }}
>
Voir le fichier brut
</a>
<a
href={lastSchedule?.exportCsvUrl}
target="_blank"
rel="noreferrer"
style={{ pointerEvents: lastSchedule?.exportCsvUrl ? "auto" : "none", opacity: lastSchedule?.exportCsvUrl ? 1 : 0.6 }}
>
Télécharger analyse
</a>
</div>
{mode === "day" ? (
<TimeGridMulti columns={dayColumns} timeZone={timeZone ?? undefined} showNowLine now={new Date()} />
) : (
<TimeGridMulti columns={weekColumns} timeZone={timeZone ?? undefined} showNowLine now={new Date()} />
)}
</Container>
);
};

View File

@@ -0,0 +1,100 @@
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
import { useEffect, useMemo, useState } from "react";
import styled from "styled-components";
import { useNavigate } from "react-router-dom";
import { useChildren } from "../state/ChildrenContext";
import { ChildCard } from "../components/ChildCard";
import { ChildProfilePanel } from "../components/ChildProfilePanel";
import { uploadPlanning } from "../services/api-client";
const Container = styled.div `
display: flex;
gap: 24px;
align-items: flex-start;
`;
const List = styled.div `
flex: 2;
display: grid;
gap: 16px;
`;
const Title = styled.h1 `
margin: 0 0 12px;
font-size: 1.8rem;
`;
const Description = styled.p `
margin: 0 0 24px;
color: var(--text-muted);
`;
const StatusMessage = styled.div `
padding: 16px;
border-radius: 16px;
border: 1px solid rgba(126, 136, 180, 0.3);
background: rgba(16, 22, 52, 0.7);
color: var(--text-muted);
`;
export const ChildrenScreen = () => {
const { children, loading, error, deleteChild } = useChildren();
const navigate = useNavigate();
const [actionError, setActionError] = useState(null);
const [lastPlanUrls, setLastPlanUrls] = useState(() => {
try {
const raw = localStorage.getItem("fp:lastPlanUrls");
return raw ? JSON.parse(raw) : {};
}
catch {
return {};
}
});
useEffect(() => {
try {
localStorage.setItem("fp:lastPlanUrls", JSON.stringify(lastPlanUrls));
}
catch {
// ignore persistence errors
}
}, [lastPlanUrls]);
const [editingChildId, setEditingChildId] = useState(null);
const selectedChild = useMemo(() => children.find((child) => child.id === editingChildId) ?? null, [children, editingChildId]);
useEffect(() => {
if (editingChildId && !selectedChild) {
setEditingChildId(null);
}
}, [editingChildId, selectedChild]);
const handleDelete = async (childId) => {
setActionError(null);
try {
const confirmed = window.confirm("Êtes-vous sûr de vouloir supprimer cet enfant ? Il sera archivé et pourra être restauré depuis Paramètres > Historique & restauration.");
if (!confirmed)
return;
await deleteChild(childId);
if (editingChildId === childId) {
setEditingChildId(null);
}
}
catch (err) {
setActionError("La suppression a echoue. Reessaye plus tard.");
}
};
const handleImport = async (childId, file) => {
try {
const result = await uploadPlanning(childId, file);
const sourceUrl = result?.schedule?.sourceFileUrl;
if (sourceUrl) {
setLastPlanUrls((prev) => ({ ...prev, [childId]: sourceUrl }));
}
alert(`Planning importe pour ${childId}. Activites detectees: ${result?.schedule?.activities?.length ?? 0}${sourceUrl ? "\nClique sur Voir planning pour louvrir." : ""}`);
}
catch (e) {
alert("Echec de l'import du planning.");
}
};
const handleViewPlanning = (childId) => {
const url = lastPlanUrls[childId];
if (url) {
window.open(url, "_blank", "noopener,noreferrer");
}
else {
alert("Aucun planning importe pour cet enfant. Utilise Importer dabord.");
}
};
return (_jsxs(_Fragment, { children: [_jsx(Title, { children: "Gestion des enfants" }), _jsx(Description, { children: "Cree les profils et ajoute les informations a connaitre pour preparer les journees." }), _jsxs(Container, { children: [_jsxs(List, { children: [loading && _jsx(StatusMessage, { children: "Chargement des informations..." }), error && !loading && _jsx(StatusMessage, { children: error }), actionError && _jsx(StatusMessage, { children: actionError }), !loading && !error && children.length === 0 && (_jsx(StatusMessage, { children: "Aucun enfant renseigne pour le moment. Ajoute un profil !" })), children.map((child) => (_jsx(ChildCard, { child: child, onDelete: handleDelete, onEdit: (id) => setEditingChildId(id), onViewProfile: (id) => navigate(`/profiles/child/${id}`), onViewPlanning: handleViewPlanning }, child.id)))] }), editingChildId && selectedChild ? (_jsx(ChildProfilePanel, { mode: "edit", child: selectedChild, onCancel: () => setEditingChildId(null) })) : null] })] }));
};

View File

@@ -0,0 +1,151 @@
import { useEffect, useMemo, useState } from "react";
import styled from "styled-components";
import { useNavigate } from "react-router-dom";
import { useChildren } from "../state/ChildrenContext";
import { ChildCard } from "../components/ChildCard";
import { ChildProfilePanel } from "../components/ChildProfilePanel";
import { uploadPlanning } from "../services/api-client";
const Container = styled.div`
display: flex;
gap: 24px;
align-items: flex-start;
`;
const List = styled.div`
flex: 2;
display: grid;
gap: 16px;
`;
const Title = styled.h1`
margin: 0 0 12px;
font-size: 1.8rem;
`;
const Description = styled.p`
margin: 0 0 24px;
color: var(--text-muted);
`;
const StatusMessage = styled.div`
padding: 16px;
border-radius: 16px;
border: 1px solid rgba(126, 136, 180, 0.3);
background: rgba(16, 22, 52, 0.7);
color: var(--text-muted);
`;
export const ChildrenScreen = () => {
const { children, loading, error, deleteChild } = useChildren();
const navigate = useNavigate();
const [actionError, setActionError] = useState<string | null>(null);
const [lastPlanUrls, setLastPlanUrls] = useState<Record<string, string>>(() => {
try {
const raw = localStorage.getItem("fp:lastPlanUrls");
return raw ? (JSON.parse(raw) as Record<string, string>) : {};
} catch {
return {};
}
});
useEffect(() => {
try {
localStorage.setItem("fp:lastPlanUrls", JSON.stringify(lastPlanUrls));
} catch {
// ignore persistence errors
}
}, [lastPlanUrls]);
const [editingChildId, setEditingChildId] = useState<string | null>(null);
const selectedChild = useMemo(
() => children.find((child) => child.id === editingChildId) ?? null,
[children, editingChildId]
);
useEffect(() => {
if (editingChildId && !selectedChild) {
setEditingChildId(null);
}
}, [editingChildId, selectedChild]);
const handleDelete = async (childId: string) => {
setActionError(null);
try {
const confirmed = window.confirm(
"Êtes-vous sûr de vouloir supprimer cet enfant ? Il sera archivé et pourra être restauré depuis Paramètres > Historique & restauration."
);
if (!confirmed) return;
await deleteChild(childId);
if (editingChildId === childId) {
setEditingChildId(null);
}
} catch (err) {
setActionError("La suppression a echoue. Reessaye plus tard.");
}
};
const handleImport = async (childId: string, file: File) => {
try {
const result = await uploadPlanning(childId, file);
const sourceUrl = result?.schedule?.sourceFileUrl as string | undefined;
if (sourceUrl) {
setLastPlanUrls((prev) => ({ ...prev, [childId]: sourceUrl }));
}
alert(
`Planning importe pour ${childId}. Activites detectees: ${
result?.schedule?.activities?.length ?? 0
}${sourceUrl ? "\nClique sur Voir planning pour louvrir." : ""}`
);
} catch (e) {
alert("Echec de l'import du planning.");
}
};
const handleViewPlanning = (childId: string) => {
const url = lastPlanUrls[childId];
if (url) {
window.open(url, "_blank", "noopener,noreferrer");
} else {
alert("Aucun planning importe pour cet enfant. Utilise Importer dabord.");
}
};
return (
<>
<Title>Gestion des enfants</Title>
<Description>
Cree les profils et ajoute les informations a connaitre pour preparer les journees.
</Description>
<Container>
<List>
{loading && <StatusMessage>Chargement des informations...</StatusMessage>}
{error && !loading && <StatusMessage>{error}</StatusMessage>}
{actionError && <StatusMessage>{actionError}</StatusMessage>}
{!loading && !error && children.length === 0 && (
<StatusMessage>Aucun enfant renseigne pour le moment. Ajoute un profil !</StatusMessage>
)}
{children.map((child) => (
<ChildCard
key={child.id}
child={child}
onDelete={handleDelete}
onEdit={(id) => setEditingChildId(id)}
onViewProfile={(id) => navigate(`/profiles/child/${id}`)}
onViewPlanning={handleViewPlanning}
/>
))}
</List>
{editingChildId && selectedChild ? (
<ChildProfilePanel
mode="edit"
child={selectedChild}
onCancel={() => setEditingChildId(null)}
/>
) : null}
</Container>
</>
);
};

View File

@@ -0,0 +1,439 @@
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
import { useEffect, useMemo, useState } from "react";
import styled from "styled-components";
import { SampleScheduleGrid } from "../components/SampleScheduleGrid";
import { UpcomingAlerts } from "../components/UpcomingAlerts";
import { TimeGridMulti } from "../components/TimeGridMulti";
import { useChildren } from "../state/ChildrenContext";
import { apiClient, listParents, listGrandParents } from "../services/api-client";
const Header = styled.header `
display: flex;
flex-direction: column;
gap: 12px;
`;
const Title = styled.h1 `
margin: 0;
font-size: 2rem;
letter-spacing: 0.6px;
`;
const SubTitle = styled.p `
margin: 0;
color: var(--text-muted);
`;
const Tabs = styled.div `
display: inline-flex;
gap: 12px;
padding: 8px;
background: rgba(24, 30, 60, 0.85);
border: 1px solid rgba(126, 136, 180, 0.24);
border-radius: 16px;
`;
const TabButton = styled.button `
padding: 10px 16px;
border-radius: 12px;
border: none;
cursor: pointer;
font-weight: 600;
color: ${({ $active }) => ($active ? "#ffffff" : "var(--text-muted)")};
background: ${({ $active, $color }) => $active ? `${$color ?? "var(--brand-primary)"}33` : "transparent"};
border: 1px solid
${({ $active, $color }) => ($active ? `${$color ?? "#5562ff"}66` : "transparent")};
transition: background 0.2s ease, color 0.2s ease;
`;
const ContentGrid = styled.div `
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
gap: 24px;
`;
const TopRow = styled.div `
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
`;
const CurrentTimeBadge = styled.span `
padding: 8px 14px;
border-radius: 999px;
background: rgba(86, 115, 255, 0.16);
border: 1px solid rgba(86, 115, 255, 0.32);
color: #dfe6ff;
font-weight: 700;
font-variant-numeric: tabular-nums;
`;
const AnalyzingBar = styled.div `
padding: 10px 12px;
border-radius: 10px;
border: 1px solid rgba(126, 136, 180, 0.3);
background: rgba(16, 22, 52, 0.7);
color: #dfe6ff;
font-weight: 600;
`;
const ProfileToggleRow = styled.div `
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
`;
const ProfileToggle = styled.button `
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 999px;
border: 1px solid ${({ $active, $color }) => ($active ? $color : "rgba(126, 136, 180, 0.3)")};
background: ${({ $active, $color }) => ($active ? `${$color}33` : "rgba(16, 22, 52, 0.7)")};
color: #e9ebff;
font-size: 0.9rem;
cursor: pointer;
transition: background 0.2s ease, border-color 0.2s ease;
`;
const ToggleDot = styled.span `
width: 10px;
height: 10px;
border-radius: 50%;
background: ${({ $color }) => $color};
box-shadow: 0 0 6px ${({ $color }) => `${$color}66`};
`;
const PresetButtons = styled.div `
display: inline-flex;
gap: 8px;
`;
const PresetButton = styled.button `
padding: 8px 12px;
border-radius: 10px;
border: 1px solid rgba(126, 136, 180, 0.25);
background: ${({ $active }) => ($active ? "rgba(85,98,255,0.2)" : "transparent")};
color: #e9ebff;
cursor: pointer;
`;
const StatusMessage = styled.div `
padding: 12px;
border-radius: 12px;
border: 1px solid rgba(126, 136, 180, 0.3);
background: rgba(16, 22, 52, 0.7);
color: var(--text-muted);
`;
export const DashboardScreen = () => {
const { children, loading } = useChildren();
const [parents, setParents] = useState([]);
const [grandParents, setGrandParents] = useState([]);
const [selectedProfiles, setSelectedProfiles] = useState(() => {
try {
const raw = localStorage.getItem("fp:selectedProfiles:dashboard");
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
return parsed.filter((id) => typeof id === "string");
}
}
}
catch { }
return [];
});
const [activeTab, setActiveTab] = useState("all");
const [dayActivities, setDayActivities] = useState({});
const [dayEventsMap, setDayEventsMap] = useState({});
const [loadingSchedules, setLoadingSchedules] = useState(false);
const [now, setNow] = useState(new Date());
const [preset, setPreset] = useState("now");
const [timeZone, setTimeZone] = useState(null);
const [analyzingCount, setAnalyzingCount] = useState(() => Number(localStorage.getItem("fp:analysisCount") ?? "0"));
const allProfiles = useMemo(() => {
const childProfiles = children.map((child) => ({
id: child.id,
kind: "child",
fullName: child.fullName,
color: child.colorHex,
avatarUrl: child.avatar?.url ?? null
}));
const parentProfiles = parents.map((parent) => ({
id: parent.id,
kind: "parent",
fullName: parent.fullName,
color: parent.colorHex,
avatarUrl: parent.avatar?.url ?? null
}));
const grandProfiles = grandParents.map((grandParent) => ({
id: grandParent.id,
kind: "grandparent",
fullName: grandParent.fullName,
color: grandParent.colorHex,
avatarUrl: grandParent.avatar?.url ?? null
}));
return [...childProfiles, ...parentProfiles, ...grandProfiles];
}, [children, parents, grandParents]);
const tabs = useMemo(() => [
{ id: "all", label: "Vue générale", color: "#5562ff" },
...allProfiles.map((profile) => ({
id: profile.id,
label: profile.fullName.split(" ")[0] ?? profile.fullName,
color: profile.color
}))
], [allProfiles]);
useEffect(() => {
const fetchAdults = async () => {
try {
const [parentsData, grandParentsData] = await Promise.all([listParents(), listGrandParents()]);
setParents(parentsData);
setGrandParents(grandParentsData);
}
catch (error) {
console.warn("Impossible de charger les profils adultes", error);
}
};
void fetchAdults();
}, []);
useEffect(() => {
if (allProfiles.length === 0)
return;
setSelectedProfiles((prev) => {
const available = new Set(allProfiles.map((profile) => profile.id));
const filtered = prev.filter((id) => available.has(id));
const fallback = allProfiles.map((profile) => profile.id);
const next = filtered.length > 0 ? filtered : fallback;
const unchanged = next.length === prev.length && next.every((id, idx) => prev[idx] === id);
return unchanged ? prev : next;
});
}, [allProfiles]);
useEffect(() => {
if (activeTab !== "all" && !allProfiles.some((profile) => profile.id === activeTab)) {
setActiveTab("all");
}
}, [activeTab, allProfiles]);
useEffect(() => {
try {
localStorage.setItem("fp:selectedProfiles:dashboard", JSON.stringify(selectedProfiles));
}
catch { }
}, [selectedProfiles]);
const toggleProfile = (id) => {
setSelectedProfiles((prev) => prev.includes(id) ? prev.filter((value) => value !== id) : [...prev, id]);
};
const profileNameById = useMemo(() => {
const map = {};
for (const profile of allProfiles) {
map[profile.id] = profile.fullName;
}
return map;
}, [allProfiles]);
useEffect(() => {
const refreshSec = Number(localStorage.getItem("fp:view:autoRefreshSec") ?? "60");
const interval = isFinite(refreshSec) && refreshSec > 0 ? refreshSec * 1000 : 60000;
const timer = setInterval(() => setNow(new Date()), interval);
return () => clearInterval(timer);
}, []);
useEffect(() => {
const saved = localStorage.getItem("fp:view:timeZone");
if (!saved || saved === "auto") {
setTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone);
}
else {
setTimeZone(saved);
}
}, []);
useEffect(() => {
const onStorage = (event) => {
if (event.key === "fp:analysisCount" && event.newValue != null) {
setAnalyzingCount(Number(event.newValue) || 0);
}
};
window.addEventListener("storage", onStorage);
let channel = null;
try {
channel = new BroadcastChannel("fp-analysis");
channel.onmessage = () => {
const val = Number(localStorage.getItem("fp:analysisCount") ?? "0");
setAnalyzingCount(val);
};
}
catch { }
return () => {
window.removeEventListener("storage", onStorage);
try {
channel?.close();
}
catch { }
};
}, []);
const today = useMemo(() => new Date(), []);
useEffect(() => {
const load = async () => {
setLoadingSchedules(true);
try {
const y = today.getFullYear();
const m = String(today.getMonth() + 1).padStart(2, "0");
const d = String(today.getDate()).padStart(2, "0");
const date = `${y}-${m}-${d}`;
const resp = await apiClient.get(`/schedules/day/activities?date=${date}`);
const mapForCards = {};
const rawMap = {};
for (const item of resp?.items ?? []) {
const child = children.find((c) => c.id === item.childId);
const color = child?.colorHex ?? "#5562ff";
const sortedActs = (item.activities ?? []).sort((a, b) => a.startDateTime.localeCompare(b.startDateTime));
mapForCards[item.childId] = sortedActs.map((activity) => ({
day: "",
title: activity.title,
color,
time: `${new Date(activity.startDateTime).toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })} - ${new Date(activity.endDateTime).toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })}`,
description: activity.notes
}));
rawMap[item.childId] = sortedActs;
}
setDayActivities(mapForCards);
setDayEventsMap(rawMap);
}
catch (error) {
console.warn("Impossible de charger les activités du jour", error);
setDayActivities({});
setDayEventsMap({});
}
finally {
setLoadingSchedules(false);
}
};
void load();
}, [today, children]);
const { currentDayLabel, dateLabel } = useMemo(() => {
const dayMap = {
0: { short: "Dim", long: "dimanche" },
1: { short: "Lun", long: "lundi" },
2: { short: "Mar", long: "mardi" },
3: { short: "Mer", long: "mercredi" },
4: { short: "Jeu", long: "jeudi" },
5: { short: "Ven", long: "vendredi" },
6: { short: "Sam", long: "samedi" }
};
const weekday = today.getDay();
const formatter = new Intl.DateTimeFormat("fr-FR", {
day: "numeric",
month: "long"
});
const formatted = formatter.format(today);
const dayInfo = dayMap[weekday];
return {
currentDayLabel: dayInfo.short,
dateLabel: `${dayInfo.long} ${formatted}`.toLowerCase()
};
}, [today]);
const { scheduleEntries, gridTitle, gridSubtitle } = useMemo(() => {
if (activeTab === "all") {
return {
scheduleEntries: [],
gridTitle: "Planning du jour",
gridSubtitle: loadingSchedules ? "Chargement..." : ""
};
}
const entries = dayActivities[activeTab] ?? [];
const name = profileNameById[activeTab] ?? "ce profil";
return {
scheduleEntries: entries,
gridTitle: `Planning de ${name}`,
gridSubtitle: entries.length
? "Activités du jour"
: loadingSchedules
? "Chargement..."
: "Aucune activité planifiée pour le moment."
};
}, [activeTab, profileNameById, dayActivities, loadingSchedules]);
const activeTabColor = tabs.find((tab) => tab.id === activeTab)?.color;
const { startHour, endHour } = useMemo(() => {
const minutesInDay = 24 * 60;
const currentMinutes = (() => {
if (!timeZone)
return now.getHours() * 60 + now.getMinutes();
const fmt = new Intl.DateTimeFormat("fr-FR", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone
});
const parts = fmt.formatToParts(now);
const hh = Number(parts.find((p) => p.type === "hour")?.value ?? "0");
const mm = Number(parts.find((p) => p.type === "minute")?.value ?? "0");
return hh * 60 + mm;
})();
const minHours = Math.max(1, Number(localStorage.getItem("fp:view:minHours") ?? "8"));
const maxHours = Math.max(minHours, Number(localStorage.getItem("fp:view:maxHours") ?? "12"));
let earliest;
let latest;
if (preset === "morning") {
earliest = 8 * 60;
latest = earliest + Math.max(minHours, 8) * 60;
}
else if (preset === "afternoon") {
earliest = 12 * 60;
latest = earliest + Math.max(minHours, 8) * 60;
}
else {
earliest = currentMinutes - 60;
latest = currentMinutes + 7 * 60;
}
earliest = Math.max(0, earliest);
latest = Math.min(minutesInDay, latest);
const span = latest - earliest;
const minSpan = minHours * 60;
const maxSpan = maxHours * 60;
if (span < minSpan) {
const need = minSpan - span;
const extendForward = Math.min(need, minutesInDay - latest);
latest += extendForward;
const remaining = need - extendForward;
if (remaining > 0)
earliest = Math.max(0, earliest - remaining);
}
else if (span > maxSpan) {
latest = earliest + maxSpan;
}
const start = Math.max(0, Math.floor(earliest / 60));
const end = Math.min(24, Math.ceil(latest / 60));
return { startHour: start, endHour: end };
}, [now, preset, timeZone]);
const nowLabel = useMemo(() => {
const fmt = new Intl.DateTimeFormat("fr-FR", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: timeZone ?? undefined
});
return fmt.format(now);
}, [now, timeZone]);
const profileColumns = useMemo(() => {
const selected = new Set(selectedProfiles);
return allProfiles
.filter((profile) => selected.has(profile.id))
.map((profile) => {
const initials = profile.fullName
.split(" ")
.filter(Boolean)
.map((part) => part.charAt(0))
.join("")
.slice(0, 2)
.toUpperCase();
const events = profile.kind === "child"
? (dayEventsMap[profile.id] ?? []).map((activity) => ({
id: `${profile.id}_${activity.startDateTime}`,
title: activity.title,
startDateTime: activity.startDateTime,
endDateTime: activity.endDateTime,
color: profile.color,
notes: activity.notes
}))
: [];
return {
id: profile.id,
title: profile.fullName,
color: profile.color,
avatarUrl: profile.avatarUrl ?? undefined,
initials,
link: profile.kind === "child" ? `/children/${profile.id}/planning` : undefined,
events
};
});
}, [allProfiles, selectedProfiles, dayEventsMap]);
return (_jsxs(_Fragment, { children: [_jsxs(Header, { children: [_jsxs(TopRow, { children: [_jsxs("div", { children: [_jsx(Title, { children: "Agenda familial" }), _jsx(SubTitle, { children: "Visualise et alterne entre la vue g\u00E9n\u00E9rale et les plannings individuels." }), _jsxs("div", { style: { fontSize: "0.95rem", color: "var(--text-muted)" }, children: [currentDayLabel, " \u00B7 ", dateLabel] })] }), _jsx(CurrentTimeBadge, { children: nowLabel })] }), _jsx(Tabs, { children: tabs.map((tab) => (_jsx(TabButton, { "$active": tab.id === activeTab, "$color": tab.color, type: "button", onClick: () => setActiveTab(tab.id), children: tab.label }, tab.id))) })] }), activeTab === "all" ? (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 24 }, children: [analyzingCount > 0 ? _jsx(AnalyzingBar, { children: "Analyse en cours..." }) : null, _jsxs(PresetButtons, { children: [_jsx(PresetButton, { "$active": preset === "now", onClick: () => setPreset("now"), children: "Maintenant" }), _jsx(PresetButton, { "$active": preset === "morning", onClick: () => setPreset("morning"), children: "Matin" }), _jsx(PresetButton, { "$active": preset === "afternoon", onClick: () => setPreset("afternoon"), children: "Apr\u00E8s-midi" })] }), allProfiles.length > 0 ? (_jsx(ProfileToggleRow, { children: allProfiles.map((profile) => {
const active = selectedProfiles.includes(profile.id);
return (_jsxs(ProfileToggle, { type: "button", "$active": active, "$color": profile.color, onClick: () => toggleProfile(profile.id), children: [_jsx(ToggleDot, { "$color": profile.color }), profile.fullName] }, profile.id));
}) })) : null, profileColumns.length === 0 ? _jsx(StatusMessage, { children: "Aucun profil s\u00E9lectionn\u00E9." }) : null, _jsx(TimeGridMulti, { columns: profileColumns, startHour: startHour, endHour: endHour, showNowLine: true, now: now, timeZone: timeZone ?? undefined }), _jsx(UpcomingAlerts, { activeChildId: undefined, loading: loading })] })) : (_jsxs(ContentGrid, { children: [_jsx(SampleScheduleGrid, { title: gridTitle, subtitle: gridSubtitle, entries: scheduleEntries }), _jsx(UpcomingAlerts, { activeChildId: activeTab, loading: loading })] }))] }));
};

View File

@@ -0,0 +1,577 @@
import { useEffect, useMemo, useState } from "react";
import styled from "styled-components";
import { SampleScheduleGrid } from "../components/SampleScheduleGrid";
import { UpcomingAlerts } from "../components/UpcomingAlerts";
import { TimeGridMulti } from "../components/TimeGridMulti";
import { useChildren } from "../state/ChildrenContext";
import { apiClient, listParents, listGrandParents } from "../services/api-client";
type ScheduleEntry = {
day: string;
title: string;
color: string;
time: string;
description?: string;
};
type ScheduleTemplate = ScheduleEntry & { childId?: string };
type AdultProfile = {
id: string;
fullName: string;
colorHex: string;
email?: string;
notes?: string;
avatar?: { kind: "preset" | "custom"; url: string; name?: string };
};
type DashboardProfile = {
id: string;
kind: "child" | "parent" | "grandparent";
fullName: string;
color: string;
avatarUrl?: string | null;
};
const Header = styled.header`
display: flex;
flex-direction: column;
gap: 12px;
`;
const Title = styled.h1`
margin: 0;
font-size: 2rem;
letter-spacing: 0.6px;
`;
const SubTitle = styled.p`
margin: 0;
color: var(--text-muted);
`;
const Tabs = styled.div`
display: inline-flex;
gap: 12px;
padding: 8px;
background: rgba(24, 30, 60, 0.85);
border: 1px solid rgba(126, 136, 180, 0.24);
border-radius: 16px;
`;
const TabButton = styled.button<{ $active?: boolean; $color?: string }>`
padding: 10px 16px;
border-radius: 12px;
border: none;
cursor: pointer;
font-weight: 600;
color: ${({ $active }) => ($active ? "#ffffff" : "var(--text-muted)")};
background: ${({ $active, $color }) =>
$active ? `${$color ?? "var(--brand-primary)"}33` : "transparent"};
border: 1px solid
${({ $active, $color }) => ($active ? `${$color ?? "#5562ff"}66` : "transparent")};
transition: background 0.2s ease, color 0.2s ease;
`;
const ContentGrid = styled.div`
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
gap: 24px;
`;
const TopRow = styled.div`
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
`;
const CurrentTimeBadge = styled.span`
padding: 8px 14px;
border-radius: 999px;
background: rgba(86, 115, 255, 0.16);
border: 1px solid rgba(86, 115, 255, 0.32);
color: #dfe6ff;
font-weight: 700;
font-variant-numeric: tabular-nums;
`;
const AnalyzingBar = styled.div`
padding: 10px 12px;
border-radius: 10px;
border: 1px solid rgba(126, 136, 180, 0.3);
background: rgba(16, 22, 52, 0.7);
color: #dfe6ff;
font-weight: 600;
`;
const ProfileToggleRow = styled.div`
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
`;
const ProfileToggle = styled.button<{ $active: boolean; $color: string }>`
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 999px;
border: 1px solid ${({ $active, $color }) => ($active ? $color : "rgba(126, 136, 180, 0.3)")};
background: ${({ $active, $color }) => ($active ? `${$color}33` : "rgba(16, 22, 52, 0.7)")};
color: #e9ebff;
font-size: 0.9rem;
cursor: pointer;
transition: background 0.2s ease, border-color 0.2s ease;
`;
const ToggleDot = styled.span<{ $color: string }>`
width: 10px;
height: 10px;
border-radius: 50%;
background: ${({ $color }) => $color};
box-shadow: 0 0 6px ${({ $color }) => `${$color}66`};
`;
const PresetButtons = styled.div`
display: inline-flex;
gap: 8px;
`;
const PresetButton = styled.button<{ $active?: boolean }>`
padding: 8px 12px;
border-radius: 10px;
border: 1px solid rgba(126, 136, 180, 0.25);
background: ${({ $active }) => ($active ? "rgba(85,98,255,0.2)" : "transparent")};
color: #e9ebff;
cursor: pointer;
`;
const StatusMessage = styled.div`
padding: 12px;
border-radius: 12px;
border: 1px solid rgba(126, 136, 180, 0.3);
background: rgba(16, 22, 52, 0.7);
color: var(--text-muted);
`;
export const DashboardScreen = () => {
const { children, loading } = useChildren();
const [parents, setParents] = useState<AdultProfile[]>([]);
const [grandParents, setGrandParents] = useState<AdultProfile[]>([]);
const [selectedProfiles, setSelectedProfiles] = useState<string[]>(() => {
try {
const raw = localStorage.getItem("fp:selectedProfiles:dashboard");
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
return parsed.filter((id): id is string => typeof id === "string");
}
}
} catch {}
return [];
});
const [activeTab, setActiveTab] = useState<string>("all");
const [dayActivities, setDayActivities] = useState<Record<string, ScheduleEntry[]>>({});
const [dayEventsMap, setDayEventsMap] = useState<Record<string, { title: string; startDateTime: string; endDateTime: string; notes?: string }[]>>({});
const [loadingSchedules, setLoadingSchedules] = useState<boolean>(false);
const [now, setNow] = useState<Date>(new Date());
const [preset, setPreset] = useState<"now" | "morning" | "afternoon">("now");
const [timeZone, setTimeZone] = useState<string | null>(null);
const [analyzingCount, setAnalyzingCount] = useState<number>(() => Number(localStorage.getItem("fp:analysisCount") ?? "0"));
const allProfiles = useMemo<DashboardProfile[]>(() => {
const childProfiles: DashboardProfile[] = children.map((child) => ({
id: child.id,
kind: "child",
fullName: child.fullName,
color: child.colorHex,
avatarUrl: child.avatar?.url ?? null
}));
const parentProfiles: DashboardProfile[] = parents.map((parent) => ({
id: parent.id,
kind: "parent",
fullName: parent.fullName,
color: parent.colorHex,
avatarUrl: parent.avatar?.url ?? null
}));
const grandProfiles: DashboardProfile[] = grandParents.map((grandParent) => ({
id: grandParent.id,
kind: "grandparent",
fullName: grandParent.fullName,
color: grandParent.colorHex,
avatarUrl: grandParent.avatar?.url ?? null
}));
return [...childProfiles, ...parentProfiles, ...grandProfiles];
}, [children, parents, grandParents]);
const tabs = useMemo(
() => [
{ id: "all", label: "Vue générale", color: "#5562ff" },
...allProfiles.map((profile) => ({
id: profile.id,
label: profile.fullName.split(" ")[0] ?? profile.fullName,
color: profile.color
}))
],
[allProfiles]
);
useEffect(() => {
const fetchAdults = async () => {
try {
const [parentsData, grandParentsData] = await Promise.all([listParents(), listGrandParents()]);
setParents(parentsData as AdultProfile[]);
setGrandParents(grandParentsData as AdultProfile[]);
} catch (error) {
console.warn("Impossible de charger les profils adultes", error);
}
};
void fetchAdults();
}, []);
useEffect(() => {
if (allProfiles.length === 0) return;
setSelectedProfiles((prev) => {
const available = new Set(allProfiles.map((profile) => profile.id));
const filtered = prev.filter((id) => available.has(id));
const fallback = allProfiles.map((profile) => profile.id);
const next = filtered.length > 0 ? filtered : fallback;
const unchanged = next.length === prev.length && next.every((id, idx) => prev[idx] === id);
return unchanged ? prev : next;
});
}, [allProfiles]);
useEffect(() => {
if (activeTab !== "all" && !allProfiles.some((profile) => profile.id === activeTab)) {
setActiveTab("all");
}
}, [activeTab, allProfiles]);
useEffect(() => {
try {
localStorage.setItem("fp:selectedProfiles:dashboard", JSON.stringify(selectedProfiles));
} catch {}
}, [selectedProfiles]);
const toggleProfile = (id: string) => {
setSelectedProfiles((prev) =>
prev.includes(id) ? prev.filter((value) => value !== id) : [...prev, id]
);
};
const profileNameById = useMemo(() => {
const map: Record<string, string> = {};
for (const profile of allProfiles) {
map[profile.id] = profile.fullName;
}
return map;
}, [allProfiles]);
useEffect(() => {
const refreshSec = Number(localStorage.getItem("fp:view:autoRefreshSec") ?? "60");
const interval = isFinite(refreshSec) && refreshSec > 0 ? refreshSec * 1000 : 60_000;
const timer = setInterval(() => setNow(new Date()), interval);
return () => clearInterval(timer);
}, []);
useEffect(() => {
const saved = localStorage.getItem("fp:view:timeZone");
if (!saved || saved === "auto") {
setTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone);
} else {
setTimeZone(saved);
}
}, []);
useEffect(() => {
const onStorage = (event: StorageEvent) => {
if (event.key === "fp:analysisCount" && event.newValue != null) {
setAnalyzingCount(Number(event.newValue) || 0);
}
};
window.addEventListener("storage", onStorage);
let channel: BroadcastChannel | null = null;
try {
channel = new BroadcastChannel("fp-analysis");
channel.onmessage = () => {
const val = Number(localStorage.getItem("fp:analysisCount") ?? "0");
setAnalyzingCount(val);
};
} catch {}
return () => {
window.removeEventListener("storage", onStorage);
try {
channel?.close();
} catch {}
};
}, []);
const today = useMemo(() => new Date(), []);
useEffect(() => {
const load = async () => {
setLoadingSchedules(true);
try {
const y = today.getFullYear();
const m = String(today.getMonth() + 1).padStart(2, "0");
const d = String(today.getDate()).padStart(2, "0");
const date = `${y}-${m}-${d}`;
const resp = await apiClient.get<{
date: string;
items: Array<{ childId: string; activities: { title: string; startDateTime: string; endDateTime: string; notes?: string }[] }>;
}>(`/schedules/day/activities?date=${date}`);
const mapForCards: Record<string, ScheduleEntry[]> = {};
const rawMap: Record<string, { title: string; startDateTime: string; endDateTime: string; notes?: string }[]> = {};
for (const item of resp?.items ?? []) {
const child = children.find((c) => c.id === item.childId);
const color = child?.colorHex ?? "#5562ff";
const sortedActs = (item.activities ?? []).sort((a, b) => a.startDateTime.localeCompare(b.startDateTime));
mapForCards[item.childId] = sortedActs.map((activity) => ({
day: "",
title: activity.title,
color,
time: `${new Date(activity.startDateTime).toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })} - ${new Date(activity.endDateTime).toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })}`,
description: activity.notes
}));
rawMap[item.childId] = sortedActs;
}
setDayActivities(mapForCards);
setDayEventsMap(rawMap);
} catch (error) {
console.warn("Impossible de charger les activités du jour", error);
setDayActivities({});
setDayEventsMap({});
} finally {
setLoadingSchedules(false);
}
};
void load();
}, [today, children]);
const { currentDayLabel, dateLabel } = useMemo(() => {
const dayMap: Record<number, { short: string; long: string }> = {
0: { short: "Dim", long: "dimanche" },
1: { short: "Lun", long: "lundi" },
2: { short: "Mar", long: "mardi" },
3: { short: "Mer", long: "mercredi" },
4: { short: "Jeu", long: "jeudi" },
5: { short: "Ven", long: "vendredi" },
6: { short: "Sam", long: "samedi" }
};
const weekday = today.getDay();
const formatter = new Intl.DateTimeFormat("fr-FR", {
day: "numeric",
month: "long"
});
const formatted = formatter.format(today);
const dayInfo = dayMap[weekday];
return {
currentDayLabel: dayInfo.short,
dateLabel: `${dayInfo.long} ${formatted}`.toLowerCase()
};
}, [today]);
const { scheduleEntries, gridTitle, gridSubtitle } = useMemo(() => {
if (activeTab === "all") {
return {
scheduleEntries: [],
gridTitle: "Planning du jour",
gridSubtitle: loadingSchedules ? "Chargement..." : ""
};
}
const entries = dayActivities[activeTab] ?? [];
const name = profileNameById[activeTab] ?? "ce profil";
return {
scheduleEntries: entries,
gridTitle: `Planning de ${name}`,
gridSubtitle: entries.length
? "Activités du jour"
: loadingSchedules
? "Chargement..."
: "Aucune activité planifiée pour le moment."
};
}, [activeTab, profileNameById, dayActivities, loadingSchedules]);
const activeTabColor = tabs.find((tab) => tab.id === activeTab)?.color;
const { startHour, endHour } = useMemo(() => {
const minutesInDay = 24 * 60;
const currentMinutes = (() => {
if (!timeZone) return now.getHours() * 60 + now.getMinutes();
const fmt = new Intl.DateTimeFormat("fr-FR", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone
});
const parts = fmt.formatToParts(now);
const hh = Number(parts.find((p) => p.type === "hour")?.value ?? "0");
const mm = Number(parts.find((p) => p.type === "minute")?.value ?? "0");
return hh * 60 + mm;
})();
const minHours = Math.max(1, Number(localStorage.getItem("fp:view:minHours") ?? "8"));
const maxHours = Math.max(minHours, Number(localStorage.getItem("fp:view:maxHours") ?? "12"));
let earliest: number;
let latest: number;
if (preset === "morning") {
earliest = 8 * 60;
latest = earliest + Math.max(minHours, 8) * 60;
} else if (preset === "afternoon") {
earliest = 12 * 60;
latest = earliest + Math.max(minHours, 8) * 60;
} else {
earliest = currentMinutes - 60;
latest = currentMinutes + 7 * 60;
}
earliest = Math.max(0, earliest);
latest = Math.min(minutesInDay, latest);
const span = latest - earliest;
const minSpan = minHours * 60;
const maxSpan = maxHours * 60;
if (span < minSpan) {
const need = minSpan - span;
const extendForward = Math.min(need, minutesInDay - latest);
latest += extendForward;
const remaining = need - extendForward;
if (remaining > 0) earliest = Math.max(0, earliest - remaining);
} else if (span > maxSpan) {
latest = earliest + maxSpan;
}
const start = Math.max(0, Math.floor(earliest / 60));
const end = Math.min(24, Math.ceil(latest / 60));
return { startHour: start, endHour: end };
}, [now, preset, timeZone]);
const nowLabel = useMemo(() => {
const fmt = new Intl.DateTimeFormat("fr-FR", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: timeZone ?? undefined
});
return fmt.format(now);
}, [now, timeZone]);
const profileColumns = useMemo(() => {
const selected = new Set(selectedProfiles);
return allProfiles
.filter((profile) => selected.has(profile.id))
.map((profile) => {
const initials = profile.fullName
.split(" ")
.filter(Boolean)
.map((part) => part.charAt(0))
.join("" )
.slice(0, 2)
.toUpperCase();
const events =
profile.kind === "child"
? (dayEventsMap[profile.id] ?? []).map((activity) => ({
id: `${profile.id}_${activity.startDateTime}`,
title: activity.title,
startDateTime: activity.startDateTime,
endDateTime: activity.endDateTime,
color: profile.color,
notes: activity.notes
}))
: [];
return {
id: profile.id,
title: profile.fullName,
color: profile.color,
avatarUrl: profile.avatarUrl ?? undefined,
initials,
link: profile.kind === "child" ? `/children/${profile.id}/planning` : undefined,
events
};
});
}, [allProfiles, selectedProfiles, dayEventsMap]);
return (
<>
<Header>
<TopRow>
<div>
<Title>Agenda familial</Title>
<SubTitle>Visualise et alterne entre la vue générale et les plannings individuels.</SubTitle>
<div style={{ fontSize: "0.95rem", color: "var(--text-muted)" }}>
{currentDayLabel} · {dateLabel}
</div>
</div>
<CurrentTimeBadge>{nowLabel}</CurrentTimeBadge>
</TopRow>
<Tabs>
{tabs.map((tab) => (
<TabButton
key={tab.id}
$active={tab.id === activeTab}
$color={tab.color}
type="button"
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</TabButton>
))}
</Tabs>
</Header>
{activeTab === "all" ? (
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
{analyzingCount > 0 ? <AnalyzingBar>Analyse en cours...</AnalyzingBar> : null}
<PresetButtons>
<PresetButton $active={preset === "now"} onClick={() => setPreset("now")}>
Maintenant
</PresetButton>
<PresetButton $active={preset === "morning"} onClick={() => setPreset("morning")}>
Matin
</PresetButton>
<PresetButton $active={preset === "afternoon"} onClick={() => setPreset("afternoon")}>
Après-midi
</PresetButton>
</PresetButtons>
{allProfiles.length > 0 ? (
<ProfileToggleRow>
{allProfiles.map((profile) => {
const active = selectedProfiles.includes(profile.id);
return (
<ProfileToggle
key={profile.id}
type="button"
$active={active}
$color={profile.color}
onClick={() => toggleProfile(profile.id)}
>
<ToggleDot $color={profile.color} />
{profile.fullName}
</ProfileToggle>
);
})}
</ProfileToggleRow>
) : null}
{profileColumns.length === 0 ? <StatusMessage>Aucun profil sélectionné.</StatusMessage> : null}
<TimeGridMulti
columns={profileColumns}
startHour={startHour}
endHour={endHour}
showNowLine
now={now}
timeZone={timeZone ?? undefined}
/>
<UpcomingAlerts activeChildId={undefined} loading={loading} />
</div>
) : (
<ContentGrid>
<SampleScheduleGrid
title={gridTitle}
subtitle={gridSubtitle}
entries={scheduleEntries}
/>
<UpcomingAlerts activeChildId={activeTab} loading={loading} />
</ContentGrid>
)}
</>
);
};

View File

@@ -0,0 +1,262 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import styled from "styled-components";
import { GrandParent } from "@family-planner/types";
import { listGrandParents } from "../services/api-client";
const Container = styled.div`
max-width: 1200px;
margin: 0 auto;
`;
const Header = styled.header`
display: flex;
align-items: center;
gap: 24px;
margin-bottom: 32px;
padding: 24px;
border-radius: 18px;
background: rgba(29, 36, 66, 0.92);
border: 1px solid rgba(126, 136, 180, 0.22);
`;
const BackButton = styled.button`
padding: 10px 16px;
border-radius: 12px;
border: 1px solid rgba(126, 136, 180, 0.4);
background: rgba(16, 22, 52, 0.9);
color: #d9dcff;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s ease;
&:hover {
transform: translateY(-1px);
}
`;
const Avatar = styled.div<{ $color: string; $imageUrl?: string }>`
width: 96px;
height: 96px;
border-radius: 24px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 2rem;
background: ${({ $imageUrl, $color }) =>
$imageUrl ? `url(${$imageUrl}) center/cover` : $color};
color: ${({ $imageUrl }) => ($imageUrl ? "transparent" : "#040411")};
border: 3px solid ${({ $color }) => $color};
`;
const HeaderInfo = styled.div`
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
`;
const Title = styled.h1`
margin: 0;
font-size: 2rem;
`;
const Meta = styled.p`
margin: 0;
color: var(--text-muted);
`;
const Badge = styled.span`
display: inline-block;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 189, 89, 0.2);
border: 1px solid rgba(255, 189, 89, 0.4);
color: #ffeac4;
font-size: 0.85rem;
font-weight: 600;
`;
const Content = styled.div`
display: grid;
gap: 24px;
grid-template-columns: 1fr 1fr;
@media (max-width: 968px) {
grid-template-columns: 1fr;
}
`;
const Card = styled.section`
padding: 24px;
border-radius: 18px;
background: rgba(29, 36, 66, 0.92);
border: 1px solid rgba(126, 136, 180, 0.22);
display: flex;
flex-direction: column;
gap: 20px;
`;
const CardTitle = styled.h2`
margin: 0;
font-size: 1.4rem;
`;
const InfoRow = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px 16px;
border-radius: 12px;
background: rgba(16, 22, 52, 0.6);
border: 1px solid rgba(126, 136, 180, 0.18);
`;
const InfoLabel = styled.span`
font-size: 0.85rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
`;
const InfoValue = styled.span`
font-size: 1.1rem;
color: #e9ebff;
font-weight: 500;
`;
const StatusMessage = styled.div`
padding: 12px;
border-radius: 12px;
background: rgba(12, 18, 42, 0.7);
border: 1px solid rgba(126, 136, 180, 0.24);
color: var(--text-muted);
text-align: center;
`;
const PlaceholderCard = styled(Card)`
align-items: center;
justify-content: center;
min-height: 200px;
color: var(--text-muted);
text-align: center;
`;
export const GrandParentDetailScreen = () => {
const { grandParentId } = useParams<{ grandParentId: string }>();
const navigate = useNavigate();
const [grandParent, setGrandParent] = useState<GrandParent | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const loadGrandParent = async () => {
try {
const grandParents = await listGrandParents();
const foundGrandParent = grandParents.find((gp) => gp.id === grandParentId);
setGrandParent(foundGrandParent || null);
} catch (error) {
console.error("Erreur lors du chargement du grand-parent:", error);
} finally {
setLoading(false);
}
};
void loadGrandParent();
}, [grandParentId]);
if (loading) {
return (
<Container>
<StatusMessage>Chargement du profil...</StatusMessage>
</Container>
);
}
if (!grandParent) {
return (
<Container>
<StatusMessage>Grand-parent introuvable</StatusMessage>
</Container>
);
}
const initials = grandParent.fullName
.split(" ")
.filter(Boolean)
.map((part) => part.charAt(0))
.join("")
.slice(0, 2)
.toUpperCase();
return (
<Container>
<Header>
<BackButton onClick={() => navigate("/profiles")}> Retour</BackButton>
<Avatar
$color={grandParent.colorHex}
$imageUrl={grandParent.avatar?.url}
>
{!grandParent.avatar?.url && initials}
</Avatar>
<HeaderInfo>
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<Title>{grandParent.fullName}</Title>
<Badge>Grand-Parent</Badge>
</div>
{grandParent.email && <Meta>{grandParent.email}</Meta>}
{grandParent.notes && <Meta>{grandParent.notes}</Meta>}
<Meta style={{ fontSize: "0.85rem", marginTop: "4px" }}>
Profil créé le {new Date(grandParent.createdAt).toLocaleDateString("fr-FR")}
</Meta>
</HeaderInfo>
</Header>
<Content>
<Card>
<CardTitle>Informations</CardTitle>
<InfoRow>
<InfoLabel>Nom complet</InfoLabel>
<InfoValue>{grandParent.fullName}</InfoValue>
</InfoRow>
{grandParent.email && (
<InfoRow>
<InfoLabel>Email</InfoLabel>
<InfoValue>{grandParent.email}</InfoValue>
</InfoRow>
)}
<InfoRow>
<InfoLabel>Couleur du profil</InfoLabel>
<InfoValue style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<span
style={{
width: "20px",
height: "20px",
borderRadius: "4px",
background: grandParent.colorHex,
border: `2px solid ${grandParent.colorHex}`,
}}
/>
{grandParent.colorHex}
</InfoValue>
</InfoRow>
{grandParent.notes && (
<InfoRow>
<InfoLabel>Notes</InfoLabel>
<InfoValue>{grandParent.notes}</InfoValue>
</InfoRow>
)}
</Card>
<PlaceholderCard>
<CardTitle style={{ marginBottom: "12px" }}>Congés personnels</CardTitle>
<Meta>Les congés personnels de {grandParent.fullName.split(" ")[0]} seront affichés ici.</Meta>
<Meta style={{ fontSize: "0.85rem", marginTop: "8px" }}>
(Fonctionnalité à venir)
</Meta>
</PlaceholderCard>
</Content>
</Container>
);
};

View File

@@ -0,0 +1,647 @@
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import styled from "styled-components";
import { API_BASE_URL, uploadPlanning, listGrandParents, deleteGrandParent, updateGrandParent } from "../services/api-client";
import { ParentProfilePanel } from "../components/ParentProfilePanel";
import { PlanningIntegrationDialog } from "../components/PlanningIntegrationDialog";
import { useCalendarIntegrations } from "../state/useCalendarIntegrations";
const Container = styled.div`
max-width: 1400px;
margin: 0 auto;
`;
const Header = styled.header`
display: flex;
align-items: center;
gap: 24px;
margin-bottom: 32px;
padding: 30px;
border-radius: 24px;
background: linear-gradient(135deg, rgba(29, 36, 66, 0.95) 0%, rgba(45, 27, 78, 0.95) 100%);
border: 1px solid rgba(126, 136, 180, 0.3);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
`;
const BackButton = styled.button`
padding: 12px 20px;
border-radius: 12px;
border: 1px solid rgba(126, 136, 180, 0.4);
background: rgba(16, 22, 52, 0.9);
color: #d9dcff;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 8px;
&:hover {
transform: translateY(-2px);
background: rgba(26, 32, 62, 0.9);
}
`;
const Avatar = styled.div`
width: 120px;
height: 120px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 2.5rem;
background: ${({ $imageUrl, $color }) => $imageUrl ? `url(${$imageUrl}) center/cover` : $color};
color: ${({ $imageUrl }) => ($imageUrl ? "transparent" : "#040411")};
border: 4px solid ${({ $color }) => $color};
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
`;
const HeaderInfo = styled.div`
flex: 1;
display: flex;
flex-direction: column;
gap: 12px;
`;
const Title = styled.h1`
margin: 0;
font-size: 2rem;
font-weight: 700;
`;
const Meta = styled.div`
display: flex;
gap: 20px;
flex-wrap: wrap;
color: var(--text-muted);
font-size: 14px;
`;
const MetaItem = styled.div`
display: flex;
align-items: center;
gap: 8px;
&::before {
content: '${props => props.$icon || "•"}';
color: #66d9ff;
}
`;
const ActionButtons = styled.div`
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-top: 20px;
`;
const Button = styled.button`
padding: 12px 24px;
border-radius: 12px;
border: none;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
&:hover {
transform: translateY(-2px);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
const PrimaryButton = styled(Button)`
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #ffffff;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
&:hover {
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
}
`;
const SecondaryButton = styled(Button)`
background: rgba(255, 255, 255, 0.1);
color: white;
border: 1px solid rgba(255, 255, 255, 0.2);
&:hover {
background: rgba(255, 255, 255, 0.2);
}
`;
const DangerButton = styled(Button)`
background: rgba(255, 107, 107, 0.2);
color: #ff6b6b;
border: 1px solid rgba(255, 107, 107, 0.4);
&:hover {
background: rgba(255, 107, 107, 0.3);
}
`;
const MainGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 20px;
margin-bottom: 20px;
`;
const Card = styled.section`
padding: 25px;
border-radius: 20px;
background: rgba(29, 36, 66, 0.92);
border: 1px solid rgba(126, 136, 180, 0.22);
display: flex;
flex-direction: column;
gap: 20px;
transition: all 0.3s ease;
&:hover {
transform: translateY(-5px);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3);
}
`;
const CardTitle = styled.h2`
margin: 0;
font-size: 20px;
font-weight: 700;
display: flex;
align-items: center;
gap: 10px;
&::before {
content: '${props => props.$icon || ""}';
font-size: 24px;
color: #66d9ff;
}
`;
const PersonalNotes = styled.textarea`
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
padding: 15px;
min-height: 150px;
color: white;
font-size: 14px;
line-height: 1.6;
resize: vertical;
font-family: inherit;
&:focus {
outline: none;
border-color: #667eea;
background: rgba(255, 255, 255, 0.08);
}
`;
const StatusMessage = styled.div`
padding: 12px;
border-radius: 12px;
background: rgba(12, 18, 42, 0.7);
border: 1px solid rgba(126, 136, 180, 0.24);
color: var(--text-muted);
text-align: center;
`;
const ErrorText = styled.div`
color: #ff7b8a;
font-size: 0.9rem;
`;
const EditPanelOverlay = styled.div`
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(5px);
padding: 20px;
`;
const EditPanelWrapper = styled.div`
max-width: 700px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
`;
const VacationSection = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
`;
const DateInputGroup = styled.div`
display: flex;
gap: 12px;
align-items: end;
flex-wrap: wrap;
`;
const Label = styled.label`
display: flex;
flex-direction: column;
gap: 8px;
font-weight: 600;
font-size: 14px;
flex: 1;
min-width: 180px;
`;
const Input = styled.input`
padding: 12px 14px;
border-radius: 12px;
border: 1px solid rgba(126, 136, 180, 0.28);
background: rgba(16, 22, 52, 0.9);
color: #ffffff;
font-size: 14px;
&:focus {
outline: none;
border-color: #667eea;
}
`;
const VacationList = styled.div`
display: flex;
flex-direction: column;
gap: 12px;
max-height: 300px;
overflow-y: auto;
`;
const VacationItem = styled.div`
padding: 15px;
border-radius: 10px;
background: rgba(46, 213, 115, 0.1);
border-left: 3px solid #2ed573;
display: flex;
justify-content: space-between;
align-items: center;
`;
const VacationInfo = styled.div`
display: flex;
flex-direction: column;
gap: 4px;
`;
const VacationTitle = styled.div`
font-weight: 600;
font-size: 14px;
`;
const VacationDate = styled.div`
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
`;
const DeleteVacationButton = styled.button`
padding: 6px 12px;
border-radius: 8px;
border: 1px solid rgba(255, 107, 107, 0.4);
background: rgba(255, 107, 107, 0.1);
color: #ff6b6b;
cursor: pointer;
font-size: 12px;
transition: all 0.2s ease;
&:hover {
background: rgba(255, 107, 107, 0.2);
}
`;
export const GrandparentDetailScreen = () => {
const { grandparentId } = useParams();
const navigate = useNavigate();
const { getConnections } = useCalendarIntegrations();
const [grandparent, setGrandparent] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// États pour édition et import
const [showEditPanel, setShowEditPanel] = useState(false);
const [showImportDialog, setShowImportDialog] = useState(false);
const [importing, setImporting] = useState(false);
// États pour les congés
const [vacations, setVacations] = useState([]);
const [vacationStart, setVacationStart] = useState("");
const [vacationEnd, setVacationEnd] = useState("");
const [vacationTitle, setVacationTitle] = useState("");
const [personalNotes, setPersonalNotes] = useState("");
useEffect(() => {
loadGrandparent();
}, [grandparentId]);
const loadGrandparent = async () => {
setLoading(true);
setError(null);
try {
const grandparents = await listGrandParents();
const foundGrandparent = grandparents.find((gp) => gp.id === grandparentId);
if (foundGrandparent) {
setGrandparent(foundGrandparent);
setPersonalNotes(foundGrandparent.notes || "");
setVacations(foundGrandparent.vacations || []);
} else {
setError("Grand-parent non trouvé");
}
} catch (err) {
setError("Erreur de chargement du profil");
} finally {
setLoading(false);
}
};
const handleSaveNotes = async () => {
if (!grandparent || !grandparentId) return;
try {
await updateGrandParent(grandparentId, {
notes: personalNotes
});
alert("Notes sauvegardées !");
await loadGrandparent();
} catch (err) {
alert("Erreur lors de la sauvegarde");
}
};
const handleAddVacation = async () => {
if (!vacationStart || !vacationEnd || !vacationTitle.trim()) {
alert("Veuillez remplir tous les champs");
return;
}
const newVacation = {
id: Date.now().toString(),
title: vacationTitle,
startDate: vacationStart,
endDate: vacationEnd
};
const updatedVacations = [...vacations, newVacation];
try {
await updateGrandParent(grandparentId, {
vacations: updatedVacations
});
setVacations(updatedVacations);
setVacationTitle("");
setVacationStart("");
setVacationEnd("");
alert("Congé ajouté avec succès !");
} catch (err) {
alert("Erreur lors de l'ajout du congé");
}
};
const handleDeleteVacation = async (vacationId) => {
const updatedVacations = vacations.filter(v => v.id !== vacationId);
try {
await updateGrandParent(grandparentId, {
vacations: updatedVacations
});
setVacations(updatedVacations);
alert("Congé supprimé !");
} catch (err) {
alert("Erreur lors de la suppression");
}
};
const handleImportData = () => {
setShowImportDialog(true);
};
const handleImportPlanning = async (file) => {
setImporting(true);
try {
const result = await uploadPlanning(grandparentId, file);
alert(`Planning importé avec succès ! ${result?.schedule?.activities?.length || 0} activités détectées.`);
setShowImportDialog(false);
} catch (error) {
alert('Échec de l\'import du planning.');
} finally {
setImporting(false);
}
};
const handleOpenPlanning = () => {
navigate(`/profiles/grandparent/${grandparentId}/planning`);
};
const handleEditProfile = () => {
setShowEditPanel(true);
};
const handleDeleteProfile = async () => {
if (confirm('Êtes-vous sûr de vouloir supprimer ce profil ?')) {
try {
await deleteGrandParent(grandparentId);
navigate('/profiles');
} catch (err) {
alert('Erreur lors de la suppression du profil');
}
}
};
const formatDate = (dateStr) => {
if (!dateStr) return '';
return new Date(dateStr).toLocaleDateString("fr-FR", {
day: "numeric",
month: "long",
year: "numeric"
});
};
if (loading) {
return (_jsx(Container, { children: _jsx(StatusMessage, { children: "Chargement du profil..." }) }));
}
if (error || !grandparent) {
return (_jsx(Container, { children: _jsx(ErrorText, { children: error || "Profil introuvable" }) }));
}
const initials = grandparent.fullName
.split(" ")
.filter(Boolean)
.map((part) => part.charAt(0))
.join("")
.slice(0, 2)
.toUpperCase();
return (
_jsxs(Container, {
children: [
_jsxs(Header, {
children: [
_jsx(BackButton, { onClick: () => navigate("/profiles"), children: "← Retour" }),
_jsx(Avatar, { "$color": grandparent.colorHex, "$imageUrl": grandparent.avatar?.url, children: !grandparent.avatar?.url && initials }),
_jsxs(HeaderInfo, {
children: [
_jsx(Title, { children: grandparent.fullName }),
_jsxs(Meta, {
children: [
grandparent.email && _jsx(MetaItem, { $icon: "✉️", children: grandparent.email }),
_jsx(MetaItem, { $icon: "👴", children: "Grand-parent" })
]
}),
_jsxs(ActionButtons, {
children: [
_jsx(PrimaryButton, { onClick: handleOpenPlanning, children: [_jsx("span", { children: "📅" }), "Planning"] }),
_jsx(SecondaryButton, { onClick: handleImportData, children: [_jsx("span", { children: "📥" }), "Importer"] }),
_jsx(SecondaryButton, { onClick: handleEditProfile, children: [_jsx("span", { children: "✏️" }), "Modifier"] }),
_jsx(DangerButton, { onClick: handleDeleteProfile, children: [_jsx("span", { children: "🗑️" }), "Supprimer"] })
]
})
]
})
]
}),
_jsxs(MainGrid, {
children: [
// Congés
_jsxs(Card, {
children: [
_jsx(CardTitle, { $icon: "🏖️", children: "Congés" }),
_jsxs(VacationSection, {
children: [
_jsx(Label, {
children: [
"Titre du congé",
_jsx(Input, {
type: "text",
placeholder: "Ex: Vacances d'été",
value: vacationTitle,
onChange: (e) => setVacationTitle(e.target.value)
})
]
}),
_jsxs(DateInputGroup, {
children: [
_jsx(Label, {
children: [
"Date de début",
_jsx(Input, {
type: "date",
value: vacationStart,
onChange: (e) => setVacationStart(e.target.value)
})
]
}),
_jsx(Label, {
children: [
"Date de fin",
_jsx(Input, {
type: "date",
value: vacationEnd,
onChange: (e) => setVacationEnd(e.target.value)
})
]
}),
_jsx(SecondaryButton, {
onClick: handleAddVacation,
style: { marginTop: '28px' },
children: "Ajouter"
})
]
}),
vacations.length > 0 ? (
_jsx(VacationList, {
children: vacations.map((vacation) => (
_jsxs(VacationItem, {
children: [
_jsxs(VacationInfo, {
children: [
_jsx(VacationTitle, { children: vacation.title }),
_jsx(VacationDate, {
children: vacation.startDate === vacation.endDate
? formatDate(vacation.startDate)
: `Du ${formatDate(vacation.startDate)} au ${formatDate(vacation.endDate)}`
})
]
}),
_jsx(DeleteVacationButton, {
onClick: () => handleDeleteVacation(vacation.id),
children: "Supprimer"
})
]
}, vacation.id)
))
})
) : (
_jsx(StatusMessage, { children: "Aucun congé enregistré" })
)
]
})
]
}),
// Notes personnelles
_jsxs(Card, {
children: [
_jsx(CardTitle, { $icon: "📝", children: "Notes personnelles" }),
_jsx(PersonalNotes, {
value: personalNotes,
onChange: (e) => setPersonalNotes(e.target.value),
placeholder: "Ajoutez vos notes personnelles ici..."
}),
_jsx(PrimaryButton, { onClick: handleSaveNotes, children: [_jsx("span", { children: "💾" }), "Enregistrer les notes"] })
]
})
]
}),
// Panneau d'édition en overlay
showEditPanel && grandparent && _jsx(EditPanelOverlay, {
onClick: () => setShowEditPanel(false),
children: _jsx(EditPanelWrapper, {
onClick: (e) => e.stopPropagation(),
children: _jsx(ParentProfilePanel, {
mode: "edit",
parent: grandparent,
kind: "grandparent",
onCancel: () => {
setShowEditPanel(false);
loadGrandparent();
}
})
})
}),
// Dialogue d'import de planning
_jsx(PlanningIntegrationDialog, {
open: showImportDialog,
profileName: grandparent?.fullName || "",
onClose: () => setShowImportDialog(false),
onImportFile: handleImportPlanning,
connections: getConnections(grandparentId),
importing: importing
})
]
})
);
};

View File

@@ -0,0 +1,758 @@
import { useEffect, useMemo, useState } from "react";
import styled from "styled-components";
import { useChildren } from "../state/ChildrenContext";
import { apiClient, listParents, listGrandParents, getHolidays, getPublicHolidays, getPersonalLeaves } from "../services/api-client";
import type { Holiday, PersonalLeave } from "@family-planner/types";
const Container = styled.div`
display: flex;
flex-direction: column;
gap: 12px;
`;
const TitleRow = styled.div`
display: flex;
align-items: center;
gap: 12px;
`;
const Title = styled.h1`
margin: 0;
font-size: 1.6rem;
`;
const DatePickerButton = styled.button`
background: rgba(85, 98, 255, 0.15);
border: 1px solid rgba(85, 98, 255, 0.3);
border-radius: 8px;
padding: 8px 12px;
color: #e9ebff;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
font-size: 0.9rem;
transition: background 0.2s ease, border-color 0.2s ease;
position: relative;
&:hover {
background: rgba(85, 98, 255, 0.25);
border-color: rgba(85, 98, 255, 0.5);
}
`;
const DatePickerPopup = styled.div`
position: absolute;
top: calc(100% + 8px);
left: 0;
z-index: 1000;
background: rgba(16, 22, 52, 0.98);
border: 1px solid rgba(126, 136, 180, 0.3);
border-radius: 12px;
padding: 16px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
min-width: 280px;
`;
const CalendarHeader = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
color: #cfd6ff;
font-weight: 600;
`;
const NavButton = styled.button`
background: transparent;
border: none;
color: #e9ebff;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
font-size: 1.2rem;
transition: background 0.2s ease;
&:hover {
background: rgba(85, 98, 255, 0.2);
}
`;
const MiniGrid = styled.div`
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
`;
const MiniDayHeader = styled.div`
text-align: center;
font-size: 0.75rem;
color: #7e88b4;
padding: 4px;
font-weight: 600;
`;
const MiniDay = styled.button<{ $isToday?: boolean; $isOtherMonth?: boolean }>`
background: ${({ $isToday }) => ($isToday ? "rgba(85, 98, 255, 0.3)" : "transparent")};
border: ${({ $isToday }) => ($isToday ? "1px solid rgba(85, 98, 255, 0.5)" : "none")};
color: ${({ $isOtherMonth }) => ($isOtherMonth ? "#7e88b4" : "#e9ebff")};
cursor: pointer;
padding: 8px 4px;
border-radius: 6px;
font-size: 0.85rem;
text-align: center;
transition: background 0.2s ease;
&:hover {
background: rgba(85, 98, 255, 0.2);
}
&:disabled {
opacity: 0.3;
cursor: not-allowed;
}
`;
const ProfileToggleRow = styled.div`
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
`;
const ProfileToggle = styled.button<{ $active: boolean; $color: string }>`
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 999px;
border: 1px solid ${({ $active, $color }) => ($active ? $color : "rgba(126, 136, 180, 0.3)")};
background: ${({ $active, $color }) => ($active ? `${$color}33` : "rgba(16, 22, 52, 0.7)")};
color: #e9ebff;
font-size: 0.9rem;
cursor: pointer;
transition: background 0.2s ease, border-color 0.2s ease;
`;
const ToggleDot = styled.span<{ $color: string }>`
width: 10px;
height: 10px;
border-radius: 50%;
background: ${({ $color }) => $color};
box-shadow: 0 0 6px ${({ $color }) => `${$color}66`};
`;
const StatusMessage = styled.div`
padding: 12px;
border-radius: 12px;
border: 1px solid rgba(126, 136, 180, 0.3);
background: rgba(16, 22, 52, 0.7);
color: var(--text-muted);
`;
const Grid = styled.div`
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 8px;
`;
const Cell = styled.div<{ $isToday?: boolean }>`
min-height: 120px;
padding: 8px;
border-radius: 12px;
background: ${({ $isToday }) => ($isToday ? "rgba(85, 98, 255, 0.12)" : "rgba(16, 22, 52, 0.92)")};
border: ${({ $isToday }) => ($isToday ? "2px solid #5562ff" : "1px solid rgba(126, 136, 180, 0.22)")};
box-shadow: ${({ $isToday }) => ($isToday ? "0 0 16px rgba(85, 98, 255, 0.4)" : "none")};
display: flex;
flex-direction: column;
gap: 6px;
transition: all 0.2s ease;
`;
const DayHeader = styled.div`
font-weight: 600;
color: #cfd6ff;
`;
const Item = styled.div<{ $color: string }>`
display: flex;
gap: 6px;
align-items: center;
font-size: 0.85rem;
color: #e9ebff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&:before {
content: "";
width: 8px;
height: 8px;
border-radius: 50%;
background: ${({ $color }) => $color};
display: inline-block;
}
`;
const HolidayLine = styled.div<{ $color?: string; $opacity?: number }>`
height: 2px;
background: ${({ $color }) => $color ?? "#7d6cff"};
opacity: ${({ $opacity }) => $opacity ?? 0.8};
width: 100%;
margin: 2px 0;
`;
const MultiColorHolidayBar = styled.div`
display: flex;
align-items: center;
gap: 4px;
margin: 4px 0;
width: 100%;
`;
const HolidaySegments = styled.div`
display: flex;
height: 18px;
border-radius: 4px;
overflow: hidden;
flex: 1;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
`;
const HolidaySegment = styled.div<{ $color: string; $width: number }>`
background: ${({ $color }) => $color};
width: ${({ $width }) => $width}%;
display: flex;
align-items: center;
justify-content: center;
position: relative;
transition: all 0.2s ease;
&:hover {
filter: brightness(1.2);
}
`;
const HolidayText = styled.span<{ $singleChild: boolean }>`
font-size: ${({ $singleChild }) => $singleChild ? '0.65rem' : '0.7rem'};
color: white;
font-weight: 600;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
white-space: nowrap;
letter-spacing: ${({ $singleChild }) => $singleChild ? '0.02em' : '0.05em'};
text-transform: uppercase;
`;
const HolidayLabel = styled.div<{ $color?: string }>`
font-size: 0.7rem;
color: ${({ $color }) => $color ?? "#b8c0ff"};
text-align: center;
font-weight: 500;
margin-top: -2px;
margin-bottom: 2px;
`;
type MonthItem = { date: string; items: Array<{ childId: string; activity: { title: string; startDateTime: string; endDateTime: string } }> };
type AdultProfile = {
id: string;
fullName: string;
colorHex: string;
email?: string;
notes?: string;
avatar?: { kind: "preset" | "custom"; url: string; name?: string };
};
type DashboardProfile = {
id: string;
kind: "child" | "parent" | "grandparent";
fullName: string;
color: string;
};
export const MonthlyCalendarScreen = () => {
const { children } = useChildren();
const [parents, setParents] = useState<AdultProfile[]>([]);
const [grandParents, setGrandParents] = useState<AdultProfile[]>([]);
const [selectedProfiles, setSelectedProfiles] = useState<string[]>(() => {
try {
const raw = localStorage.getItem("fp:selectedProfiles:month");
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
return parsed.filter((id): id is string => typeof id === "string");
}
}
} catch {}
return [];
});
const [monthItems, setMonthItems] = useState<MonthItem[]>([]);
const [showDatePicker, setShowDatePicker] = useState(false);
const [viewDate, setViewDate] = useState<Date>(() => new Date());
const [pickerMonth, setPickerMonth] = useState<Date>(() => new Date());
const [holidays, setHolidays] = useState<Holiday[]>([]);
const [personalLeaves, setPersonalLeaves] = useState<PersonalLeave[]>([]);
const today = useMemo(() => new Date(), []);
const ym = `${viewDate.getFullYear()}-${String(viewDate.getMonth() + 1).padStart(2, "0")}`;
useEffect(() => {
const loadAdults = async () => {
try {
const [parentsData, grandParentsData] = await Promise.all([listParents(), listGrandParents()]);
setParents(parentsData as AdultProfile[]);
setGrandParents(grandParentsData as AdultProfile[]);
} catch (error) {
console.warn("Impossible de charger les profils adultes", error);
}
};
void loadAdults();
}, []);
useEffect(() => {
const load = async () => {
const resp = await apiClient.get<{ month: string; items: MonthItem[] }>(`/schedules/month/activities?month=${ym}`);
setMonthItems(resp.items ?? []);
};
void load();
}, [ym]);
// Track school regions to detect changes
const schoolRegionsKey = useMemo(() => {
const key = children
.map(c => `${c.id}:${c.schoolRegion || 'none'}`)
.sort()
.join('|');
console.log('🔑 schoolRegionsKey changé:', key);
return key;
}, [children]);
// Load holidays and personal leaves
useEffect(() => {
const loadHolidaysAndLeaves = async () => {
try {
// IMPORTANT: Vider IMMÉDIATEMENT les congés pour éviter tout cumul
setHolidays([]);
setPersonalLeaves([]);
// Get all school regions from children
const regions = [...new Set(children.map(c => c.schoolRegion).filter(Boolean))];
console.log('🔄 Rechargement des congés - Régions détectées:', regions);
console.log('🔍 Enfants et leurs régions:', children.map(c => ({ id: c.id, nom: c.fullName, region: c.schoolRegion })));
if (regions.length === 0) {
console.log('⚠️ Aucune région scolaire définie');
return;
}
// Load holidays for all regions
const holidayPromises = regions.map(region =>
getHolidays(region, viewDate.getFullYear()).catch(() => ({ holidays: [] }))
);
const holidayResults = await Promise.all(holidayPromises);
const allHolidays = holidayResults.flatMap(r => r.holidays);
// Also load public holidays
const publicHolidaysResult = await getPublicHolidays(viewDate.getFullYear()).catch(() => ({ holidays: [] }));
// Combine and deduplicate by id
const combinedHolidays = [...allHolidays, ...publicHolidaysResult.holidays];
const uniqueHolidays = Array.from(
new Map(combinedHolidays.map(h => [h.id, h])).values()
);
// Debug: afficher les dates des premiers congés scolaires
const sampleHolidays = allHolidays.slice(0, 3).map(h => ({
title: h.title,
start: h.startDate,
end: h.endDate,
zones: h.zones
}));
console.log('✅ Congés chargés:', {
régions: regions,
scolaires: allHolidays.length,
publics: publicHolidaysResult.holidays.length,
total: uniqueHolidays.length,
exemples: sampleHolidays
});
setHolidays(uniqueHolidays);
// Load personal leaves for all selected profiles
const leavePromises = selectedProfiles.map(profileId =>
getPersonalLeaves(profileId).catch(() => ({ leaves: [] }))
);
const leaveResults = await Promise.all(leavePromises);
const allLeaves = leaveResults.flatMap(r => r.leaves);
setPersonalLeaves(allLeaves);
} catch (error) {
console.warn("Erreur lors du chargement des congés", error);
}
};
void loadHolidaysAndLeaves();
}, [schoolRegionsKey, selectedProfiles, viewDate]);
const allProfiles = useMemo<DashboardProfile[]>(() => {
const childProfiles: DashboardProfile[] = children.map((child) => ({
id: child.id,
kind: "child",
fullName: child.fullName,
color: child.colorHex
}));
const parentProfiles: DashboardProfile[] = parents.map((parent) => ({
id: parent.id,
kind: "parent",
fullName: parent.fullName,
color: parent.colorHex
}));
const grandProfiles: DashboardProfile[] = grandParents.map((grandParent) => ({
id: grandParent.id,
kind: "grandparent",
fullName: grandParent.fullName,
color: grandParent.colorHex
}));
console.log('📊 Calendrier mensuel - Profils chargés:', {
enfants: children.length,
parents: parents.length,
grandsParents: grandParents.length,
total: childProfiles.length + parentProfiles.length + grandProfiles.length
});
return [...childProfiles, ...parentProfiles, ...grandProfiles];
}, [children, parents, grandParents]);
useEffect(() => {
if (allProfiles.length === 0) return;
setSelectedProfiles((prev) => {
const available = new Set(allProfiles.map((profile) => profile.id));
const filtered = prev.filter((id) => available.has(id));
const fallback = allProfiles.map((profile) => profile.id);
const next = filtered.length > 0 ? filtered : fallback;
const unchanged = next.length === prev.length && next.every((id, idx) => prev[idx] === id);
return unchanged ? prev : next;
});
}, [allProfiles]);
useEffect(() => {
try {
localStorage.setItem("fp:selectedProfiles:month", JSON.stringify(selectedProfiles));
} catch {}
}, [selectedProfiles]);
// Close date picker when clicking outside
useEffect(() => {
if (!showDatePicker) return;
const handleClick = () => setShowDatePicker(false);
document.addEventListener("click", handleClick);
return () => document.removeEventListener("click", handleClick);
}, [showDatePicker]);
// Reset to current month when leaving the page
useEffect(() => {
return () => {
setViewDate(new Date());
setPickerMonth(new Date());
};
}, []);
const toggleProfile = (id: string) => {
setSelectedProfiles((prev) => (prev.includes(id) ? prev.filter((value) => value !== id) : [...prev, id]));
};
const selectedChildIds = useMemo(() => {
const childIds = new Set(children.map((child) => child.id));
return new Set(selectedProfiles.filter((id) => childIds.has(id)));
}, [children, selectedProfiles]);
const weeks = useMemo(() => {
const first = new Date(viewDate.getFullYear(), viewDate.getMonth(), 1);
const start = new Date(first);
const weekday = (first.getDay() + 6) % 7;
start.setDate(first.getDate() - weekday);
const cells: { date: Date; iso: string }[] = [];
for (let i = 0; i < 42; i++) {
const d = new Date(start);
d.setDate(start.getDate() + i);
cells.push({ date: d, iso: d.toISOString().slice(0, 10) });
}
return cells;
}, [viewDate]);
const byDate = useMemo(() => {
const map: Record<string, MonthItem["items"]> = {};
for (const item of monthItems) {
const filtered = item.items.filter((entry) => selectedChildIds.size === 0 || selectedChildIds.has(entry.childId));
map[item.date] = filtered;
}
return map;
}, [monthItems, selectedChildIds]);
const childColor = (id: string) => children.find((child) => child.id === id)?.colorHex ?? "#5562ff";
// Get profile color
const getProfileColor = (profileId: string) => {
return allProfiles.find(p => p.id === profileId)?.color ?? "#7d6cff";
};
// Check if date is in holiday or personal leave - NEW VERSION WITH MULTI-CHILD SUPPORT
const getHolidaysForDate = (dateIso: string) => {
const result: Array<{
type: "school" | "public" | "personal";
label: string;
profileIds: string[]; // Changed to array to support multiple children
holidayId?: string
}> = [];
// Public holidays - affichés UNE SEULE FOIS
const publicHolidaysForDate = holidays.filter(h => {
if (h.type !== "public") return false;
// Compare ISO strings directly to avoid timezone issues
const startIso = h.startDate.slice(0, 10);
const endIso = h.endDate.slice(0, 10);
return dateIso >= startIso && dateIso <= endIso;
});
if (publicHolidaysForDate.length > 0) {
result.push({
type: "public",
label: publicHolidaysForDate[0].title,
profileIds: []
});
}
// School holidays - regrouper TOUS les enfants ayant congé
const childIdsOnHoliday = new Set<string>();
for (const child of children) {
if (!selectedProfiles.includes(child.id)) continue;
if (!child.schoolRegion) continue;
const matchingHolidays = holidays.filter(h => {
if (h.type !== "school") return false;
if (!h.zones || !h.zones.includes(child.schoolRegion!)) return false;
// Compare ISO strings directly to avoid timezone issues
const startIso = h.startDate.slice(0, 10);
const endIso = h.endDate.slice(0, 10);
return dateIso >= startIso && dateIso <= endIso;
});
if (matchingHolidays.length > 0) {
// Debug: log pour voir quels congés matchent pour quel enfant
if (dateIso === '2025-10-19' || dateIso === '2025-10-20' || dateIso === '2025-10-27') {
console.log(`📅 ${dateIso} - ${child.fullName} (${child.schoolRegion}):`,
matchingHolidays.map(h => ({ title: h.title, zones: h.zones }))
);
}
childIdsOnHoliday.add(child.id);
}
}
// Si au moins un enfant a congé, ajouter UNE SEULE entrée avec tous les IDs
if (childIdsOnHoliday.size > 0) {
result.push({
type: "school",
label: "Congés",
profileIds: Array.from(childIdsOnHoliday)
});
}
// Personal leaves - regrouper par type (adultes)
const adultIdsOnLeave = new Set<string>();
for (const leave of personalLeaves) {
// Compare ISO strings directly to avoid timezone issues
const startIso = leave.startDate.slice(0, 10);
const endIso = leave.endDate.slice(0, 10);
if (dateIso >= startIso && dateIso <= endIso) {
adultIdsOnLeave.add(leave.profileId);
}
}
if (adultIdsOnLeave.size > 0) {
result.push({
type: "personal",
label: "Congé",
profileIds: Array.from(adultIdsOnLeave)
});
}
return result;
};
// Mini calendar logic
const pickerDays = useMemo(() => {
const first = new Date(pickerMonth.getFullYear(), pickerMonth.getMonth(), 1);
const start = new Date(first);
const weekday = (first.getDay() + 6) % 7; // Monday = 0
start.setDate(first.getDate() - weekday);
const days: { date: Date; iso: string; isOtherMonth: boolean }[] = [];
for (let i = 0; i < 42; i++) {
const d = new Date(start);
d.setDate(start.getDate() + i);
days.push({
date: d,
iso: d.toISOString().slice(0, 10),
isOtherMonth: d.getMonth() !== pickerMonth.getMonth()
});
}
return days;
}, [pickerMonth]);
const handleDateSelect = (date: Date) => {
setViewDate(new Date(date));
setPickerMonth(new Date(date)); // Synchronize picker month with selected date
setShowDatePicker(false);
};
const previousMonth = () => {
setPickerMonth(prev => new Date(prev.getFullYear(), prev.getMonth() - 1, 1));
};
const nextMonth = () => {
setPickerMonth(prev => new Date(prev.getFullYear(), prev.getMonth() + 1, 1));
};
const todayIso = today.toISOString().slice(0, 10);
// Debug: afficher la date du jour
console.log('Date du jour (ISO):', todayIso);
return (
<Container>
<TitleRow>
<Title>Calendrier mensuel</Title>
<DatePickerButton onClick={(e) => {
e.stopPropagation();
setPickerMonth(new Date(viewDate)); // Center picker on current view
setShowDatePicker(!showDatePicker);
}}>
📅 {viewDate.toLocaleDateString("fr-FR", { month: "long", year: "numeric" })}
{showDatePicker && (
<DatePickerPopup onClick={(e) => e.stopPropagation()}>
<CalendarHeader>
<NavButton onClick={previousMonth}></NavButton>
<span>
{pickerMonth.toLocaleDateString("fr-FR", { month: "long", year: "numeric" })}
</span>
<NavButton onClick={nextMonth}></NavButton>
</CalendarHeader>
<MiniGrid>
{["L", "M", "M", "J", "V", "S", "D"].map((day, idx) => (
<MiniDayHeader key={idx}>{day}</MiniDayHeader>
))}
{pickerDays.map(({ date, iso, isOtherMonth }) => (
<MiniDay
key={iso}
$isToday={iso === todayIso}
$isOtherMonth={isOtherMonth}
onClick={() => handleDateSelect(date)}
>
{date.getDate()}
</MiniDay>
))}
</MiniGrid>
</DatePickerPopup>
)}
</DatePickerButton>
</TitleRow>
{allProfiles.length > 0 ? (
<ProfileToggleRow>
{allProfiles.map((profile) => {
const active = selectedProfiles.includes(profile.id);
return (
<ProfileToggle
key={profile.id}
type="button"
$active={active}
$color={profile.color}
onClick={() => toggleProfile(profile.id)}
>
<ToggleDot $color={profile.color} />
{profile.fullName}
</ProfileToggle>
);
})}
</ProfileToggleRow>
) : null}
{selectedChildIds.size === 0 ? (
<StatusMessage>Sélectionne au moins un profil enfant pour afficher des activités.</StatusMessage>
) : null}
<Grid>
{weeks.map(({ date, iso }) => {
const isToday = iso === todayIso;
if (isToday) {
console.log('Jour trouvé:', iso, 'isToday:', isToday);
}
const dateHolidays = getHolidaysForDate(iso);
return (
<Cell key={iso} $isToday={isToday}>
<DayHeader>
{date.toLocaleDateString("fr-FR", { weekday: "short", day: "2-digit", month: "2-digit" })}
</DayHeader>
{/* Afficher les congés */}
{dateHolidays.map((holiday, idx) => {
if (holiday.type === "public") {
// Public holidays: show label once
return (
<HolidayLabel key={`holiday-${idx}`} $color="#ffa726">
🎉 {holiday.label}
</HolidayLabel>
);
} else if (holiday.profileIds.length > 0) {
// School/Personal holidays: show multicolor bar
const segmentWidth = 100 / holiday.profileIds.length;
const isSingleChild = holiday.profileIds.length === 1;
return (
<MultiColorHolidayBar key={`holiday-${idx}`}>
<HolidaySegments>
{holiday.profileIds.map((profileId, segIdx) => (
<HolidaySegment
key={`${profileId}-${segIdx}`}
$color={getProfileColor(profileId)}
$width={segmentWidth}
title={allProfiles.find(p => p.id === profileId)?.fullName || ""}
>
{isSingleChild && (
<HolidayText $singleChild={true}>
{holiday.label}
</HolidayText>
)}
</HolidaySegment>
))}
</HolidaySegments>
{!isSingleChild && (
<HolidayText $singleChild={false} style={{ fontSize: '0.65rem', color: '#b8c0ff', minWidth: 'fit-content' }}>
{holiday.label}
</HolidayText>
)}
</MultiColorHolidayBar>
);
} else {
return null;
}
})}
{/* Afficher les activités */}
{(byDate[iso] ?? []).slice(0, 4).map((entry, idx) => (
<Item key={idx} $color={childColor(entry.childId)}>
{new Date(entry.activity.startDateTime).toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })}
&nbsp;{entry.activity.title}
</Item>
))}
{(byDate[iso]?.length ?? 0) > 4 ? (
<Item $color="#888">+{(byDate[iso]?.length ?? 0) - 4} autres</Item>
) : null}
</Cell>
);
})}
</Grid>
</Container>
);
};

View File

@@ -0,0 +1,647 @@
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import styled from "styled-components";
import { API_BASE_URL, uploadPlanning, listParents, deleteParent, updateParent } from "../services/api-client";
import { ParentProfilePanel } from "../components/ParentProfilePanel";
import { PlanningIntegrationDialog } from "../components/PlanningIntegrationDialog";
import { useCalendarIntegrations } from "../state/useCalendarIntegrations";
const Container = styled.div`
max-width: 1400px;
margin: 0 auto;
`;
const Header = styled.header`
display: flex;
align-items: center;
gap: 24px;
margin-bottom: 32px;
padding: 30px;
border-radius: 24px;
background: linear-gradient(135deg, rgba(29, 36, 66, 0.95) 0%, rgba(45, 27, 78, 0.95) 100%);
border: 1px solid rgba(126, 136, 180, 0.3);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
`;
const BackButton = styled.button`
padding: 12px 20px;
border-radius: 12px;
border: 1px solid rgba(126, 136, 180, 0.4);
background: rgba(16, 22, 52, 0.9);
color: #d9dcff;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 8px;
&:hover {
transform: translateY(-2px);
background: rgba(26, 32, 62, 0.9);
}
`;
const Avatar = styled.div`
width: 120px;
height: 120px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 2.5rem;
background: ${({ $imageUrl, $color }) => $imageUrl ? `url(${$imageUrl}) center/cover` : $color};
color: ${({ $imageUrl }) => ($imageUrl ? "transparent" : "#040411")};
border: 4px solid ${({ $color }) => $color};
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
`;
const HeaderInfo = styled.div`
flex: 1;
display: flex;
flex-direction: column;
gap: 12px;
`;
const Title = styled.h1`
margin: 0;
font-size: 2rem;
font-weight: 700;
`;
const Meta = styled.div`
display: flex;
gap: 20px;
flex-wrap: wrap;
color: var(--text-muted);
font-size: 14px;
`;
const MetaItem = styled.div`
display: flex;
align-items: center;
gap: 8px;
&::before {
content: '${props => props.$icon || "•"}';
color: #66d9ff;
}
`;
const ActionButtons = styled.div`
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-top: 20px;
`;
const Button = styled.button`
padding: 12px 24px;
border-radius: 12px;
border: none;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
&:hover {
transform: translateY(-2px);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
const PrimaryButton = styled(Button)`
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #ffffff;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
&:hover {
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
}
`;
const SecondaryButton = styled(Button)`
background: rgba(255, 255, 255, 0.1);
color: white;
border: 1px solid rgba(255, 255, 255, 0.2);
&:hover {
background: rgba(255, 255, 255, 0.2);
}
`;
const DangerButton = styled(Button)`
background: rgba(255, 107, 107, 0.2);
color: #ff6b6b;
border: 1px solid rgba(255, 107, 107, 0.4);
&:hover {
background: rgba(255, 107, 107, 0.3);
}
`;
const MainGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 20px;
margin-bottom: 20px;
`;
const Card = styled.section`
padding: 25px;
border-radius: 20px;
background: rgba(29, 36, 66, 0.92);
border: 1px solid rgba(126, 136, 180, 0.22);
display: flex;
flex-direction: column;
gap: 20px;
transition: all 0.3s ease;
&:hover {
transform: translateY(-5px);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3);
}
`;
const CardTitle = styled.h2`
margin: 0;
font-size: 20px;
font-weight: 700;
display: flex;
align-items: center;
gap: 10px;
&::before {
content: '${props => props.$icon || ""}';
font-size: 24px;
color: #66d9ff;
}
`;
const PersonalNotes = styled.textarea`
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
padding: 15px;
min-height: 150px;
color: white;
font-size: 14px;
line-height: 1.6;
resize: vertical;
font-family: inherit;
&:focus {
outline: none;
border-color: #667eea;
background: rgba(255, 255, 255, 0.08);
}
`;
const StatusMessage = styled.div`
padding: 12px;
border-radius: 12px;
background: rgba(12, 18, 42, 0.7);
border: 1px solid rgba(126, 136, 180, 0.24);
color: var(--text-muted);
text-align: center;
`;
const ErrorText = styled.div`
color: #ff7b8a;
font-size: 0.9rem;
`;
const EditPanelOverlay = styled.div`
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(5px);
padding: 20px;
`;
const EditPanelWrapper = styled.div`
max-width: 700px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
`;
const VacationSection = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
`;
const DateInputGroup = styled.div`
display: flex;
gap: 12px;
align-items: end;
flex-wrap: wrap;
`;
const Label = styled.label`
display: flex;
flex-direction: column;
gap: 8px;
font-weight: 600;
font-size: 14px;
flex: 1;
min-width: 180px;
`;
const Input = styled.input`
padding: 12px 14px;
border-radius: 12px;
border: 1px solid rgba(126, 136, 180, 0.28);
background: rgba(16, 22, 52, 0.9);
color: #ffffff;
font-size: 14px;
&:focus {
outline: none;
border-color: #667eea;
}
`;
const VacationList = styled.div`
display: flex;
flex-direction: column;
gap: 12px;
max-height: 300px;
overflow-y: auto;
`;
const VacationItem = styled.div`
padding: 15px;
border-radius: 10px;
background: rgba(46, 213, 115, 0.1);
border-left: 3px solid #2ed573;
display: flex;
justify-content: space-between;
align-items: center;
`;
const VacationInfo = styled.div`
display: flex;
flex-direction: column;
gap: 4px;
`;
const VacationTitle = styled.div`
font-weight: 600;
font-size: 14px;
`;
const VacationDate = styled.div`
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
`;
const DeleteVacationButton = styled.button`
padding: 6px 12px;
border-radius: 8px;
border: 1px solid rgba(255, 107, 107, 0.4);
background: rgba(255, 107, 107, 0.1);
color: #ff6b6b;
cursor: pointer;
font-size: 12px;
transition: all 0.2s ease;
&:hover {
background: rgba(255, 107, 107, 0.2);
}
`;
export const ParentDetailScreen = () => {
const { parentId } = useParams();
const navigate = useNavigate();
const { getConnections } = useCalendarIntegrations();
const [parent, setParent] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// États pour édition et import
const [showEditPanel, setShowEditPanel] = useState(false);
const [showImportDialog, setShowImportDialog] = useState(false);
const [importing, setImporting] = useState(false);
// États pour les congés
const [vacations, setVacations] = useState([]);
const [vacationStart, setVacationStart] = useState("");
const [vacationEnd, setVacationEnd] = useState("");
const [vacationTitle, setVacationTitle] = useState("");
const [personalNotes, setPersonalNotes] = useState("");
useEffect(() => {
loadParent();
}, [parentId]);
const loadParent = async () => {
setLoading(true);
setError(null);
try {
const parents = await listParents();
const foundParent = parents.find((p) => p.id === parentId);
if (foundParent) {
setParent(foundParent);
setPersonalNotes(foundParent.notes || "");
setVacations(foundParent.vacations || []);
} else {
setError("Parent non trouvé");
}
} catch (err) {
setError("Erreur de chargement du profil");
} finally {
setLoading(false);
}
};
const handleSaveNotes = async () => {
if (!parent || !parentId) return;
try {
await updateParent(parentId, {
notes: personalNotes
});
alert("Notes sauvegardées !");
await loadParent();
} catch (err) {
alert("Erreur lors de la sauvegarde");
}
};
const handleAddVacation = async () => {
if (!vacationStart || !vacationEnd || !vacationTitle.trim()) {
alert("Veuillez remplir tous les champs");
return;
}
const newVacation = {
id: Date.now().toString(),
title: vacationTitle,
startDate: vacationStart,
endDate: vacationEnd
};
const updatedVacations = [...vacations, newVacation];
try {
await updateParent(parentId, {
vacations: updatedVacations
});
setVacations(updatedVacations);
setVacationTitle("");
setVacationStart("");
setVacationEnd("");
alert("Congé ajouté avec succès !");
} catch (err) {
alert("Erreur lors de l'ajout du congé");
}
};
const handleDeleteVacation = async (vacationId) => {
const updatedVacations = vacations.filter(v => v.id !== vacationId);
try {
await updateParent(parentId, {
vacations: updatedVacations
});
setVacations(updatedVacations);
alert("Congé supprimé !");
} catch (err) {
alert("Erreur lors de la suppression");
}
};
const handleImportData = () => {
setShowImportDialog(true);
};
const handleImportPlanning = async (file) => {
setImporting(true);
try {
const result = await uploadPlanning(parentId, file);
alert(`Planning importé avec succès ! ${result?.schedule?.activities?.length || 0} activités détectées.`);
setShowImportDialog(false);
} catch (error) {
alert('Échec de l\'import du planning.');
} finally {
setImporting(false);
}
};
const handleOpenPlanning = () => {
navigate(`/profiles/parent/${parentId}/planning`);
};
const handleEditProfile = () => {
setShowEditPanel(true);
};
const handleDeleteProfile = async () => {
if (confirm('Êtes-vous sûr de vouloir supprimer ce profil ?')) {
try {
await deleteParent(parentId);
navigate('/profiles');
} catch (err) {
alert('Erreur lors de la suppression du profil');
}
}
};
const formatDate = (dateStr) => {
if (!dateStr) return '';
return new Date(dateStr).toLocaleDateString("fr-FR", {
day: "numeric",
month: "long",
year: "numeric"
});
};
if (loading) {
return (_jsx(Container, { children: _jsx(StatusMessage, { children: "Chargement du profil..." }) }));
}
if (error || !parent) {
return (_jsx(Container, { children: _jsx(ErrorText, { children: error || "Profil introuvable" }) }));
}
const initials = parent.fullName
.split(" ")
.filter(Boolean)
.map((part) => part.charAt(0))
.join("")
.slice(0, 2)
.toUpperCase();
return (
_jsxs(Container, {
children: [
_jsxs(Header, {
children: [
_jsx(BackButton, { onClick: () => navigate("/profiles"), children: "← Retour" }),
_jsx(Avatar, { "$color": parent.colorHex, "$imageUrl": parent.avatar?.url, children: !parent.avatar?.url && initials }),
_jsxs(HeaderInfo, {
children: [
_jsx(Title, { children: parent.fullName }),
_jsxs(Meta, {
children: [
parent.email && _jsx(MetaItem, { $icon: "✉️", children: parent.email }),
_jsx(MetaItem, { $icon: "👨", children: "Parent" })
]
}),
_jsxs(ActionButtons, {
children: [
_jsx(PrimaryButton, { onClick: handleOpenPlanning, children: [_jsx("span", { children: "📅" }), "Planning"] }),
_jsx(SecondaryButton, { onClick: handleImportData, children: [_jsx("span", { children: "📥" }), "Importer"] }),
_jsx(SecondaryButton, { onClick: handleEditProfile, children: [_jsx("span", { children: "✏️" }), "Modifier"] }),
_jsx(DangerButton, { onClick: handleDeleteProfile, children: [_jsx("span", { children: "🗑️" }), "Supprimer"] })
]
})
]
})
]
}),
_jsxs(MainGrid, {
children: [
// Congés
_jsxs(Card, {
children: [
_jsx(CardTitle, { $icon: "🏖️", children: "Congés" }),
_jsxs(VacationSection, {
children: [
_jsx(Label, {
children: [
"Titre du congé",
_jsx(Input, {
type: "text",
placeholder: "Ex: Vacances d'été",
value: vacationTitle,
onChange: (e) => setVacationTitle(e.target.value)
})
]
}),
_jsxs(DateInputGroup, {
children: [
_jsx(Label, {
children: [
"Date de début",
_jsx(Input, {
type: "date",
value: vacationStart,
onChange: (e) => setVacationStart(e.target.value)
})
]
}),
_jsx(Label, {
children: [
"Date de fin",
_jsx(Input, {
type: "date",
value: vacationEnd,
onChange: (e) => setVacationEnd(e.target.value)
})
]
}),
_jsx(SecondaryButton, {
onClick: handleAddVacation,
style: { marginTop: '28px' },
children: "Ajouter"
})
]
}),
vacations.length > 0 ? (
_jsx(VacationList, {
children: vacations.map((vacation) => (
_jsxs(VacationItem, {
children: [
_jsxs(VacationInfo, {
children: [
_jsx(VacationTitle, { children: vacation.title }),
_jsx(VacationDate, {
children: vacation.startDate === vacation.endDate
? formatDate(vacation.startDate)
: `Du ${formatDate(vacation.startDate)} au ${formatDate(vacation.endDate)}`
})
]
}),
_jsx(DeleteVacationButton, {
onClick: () => handleDeleteVacation(vacation.id),
children: "Supprimer"
})
]
}, vacation.id)
))
})
) : (
_jsx(StatusMessage, { children: "Aucun congé enregistré" })
)
]
})
]
}),
// Notes personnelles
_jsxs(Card, {
children: [
_jsx(CardTitle, { $icon: "📝", children: "Notes personnelles" }),
_jsx(PersonalNotes, {
value: personalNotes,
onChange: (e) => setPersonalNotes(e.target.value),
placeholder: "Ajoutez vos notes personnelles ici..."
}),
_jsx(PrimaryButton, { onClick: handleSaveNotes, children: [_jsx("span", { children: "💾" }), "Enregistrer les notes"] })
]
})
]
}),
// Panneau d'édition en overlay
showEditPanel && parent && _jsx(EditPanelOverlay, {
onClick: () => setShowEditPanel(false),
children: _jsx(EditPanelWrapper, {
onClick: (e) => e.stopPropagation(),
children: _jsx(ParentProfilePanel, {
mode: "edit",
parent: parent,
kind: "parent",
onCancel: () => {
setShowEditPanel(false);
loadParent();
}
})
})
}),
// Dialogue d'import de planning
_jsx(PlanningIntegrationDialog, {
open: showImportDialog,
profileName: parent?.fullName || "",
onClose: () => setShowImportDialog(false),
onImportFile: handleImportPlanning,
connections: getConnections(parentId),
importing: importing
})
]
})
);
};

View File

@@ -0,0 +1,262 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import styled from "styled-components";
import { Parent } from "@family-planner/types";
import { listParents } from "../services/api-client";
const Container = styled.div`
max-width: 1200px;
margin: 0 auto;
`;
const Header = styled.header`
display: flex;
align-items: center;
gap: 24px;
margin-bottom: 32px;
padding: 24px;
border-radius: 18px;
background: rgba(29, 36, 66, 0.92);
border: 1px solid rgba(126, 136, 180, 0.22);
`;
const BackButton = styled.button`
padding: 10px 16px;
border-radius: 12px;
border: 1px solid rgba(126, 136, 180, 0.4);
background: rgba(16, 22, 52, 0.9);
color: #d9dcff;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s ease;
&:hover {
transform: translateY(-1px);
}
`;
const Avatar = styled.div<{ $color: string; $imageUrl?: string }>`
width: 96px;
height: 96px;
border-radius: 24px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 2rem;
background: ${({ $imageUrl, $color }) =>
$imageUrl ? `url(${$imageUrl}) center/cover` : $color};
color: ${({ $imageUrl }) => ($imageUrl ? "transparent" : "#040411")};
border: 3px solid ${({ $color }) => $color};
`;
const HeaderInfo = styled.div`
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
`;
const Title = styled.h1`
margin: 0;
font-size: 2rem;
`;
const Meta = styled.p`
margin: 0;
color: var(--text-muted);
`;
const Badge = styled.span`
display: inline-block;
padding: 4px 12px;
border-radius: 999px;
background: rgba(125, 108, 255, 0.2);
border: 1px solid rgba(125, 108, 255, 0.4);
color: #dfe6ff;
font-size: 0.85rem;
font-weight: 600;
`;
const Content = styled.div`
display: grid;
gap: 24px;
grid-template-columns: 1fr 1fr;
@media (max-width: 968px) {
grid-template-columns: 1fr;
}
`;
const Card = styled.section`
padding: 24px;
border-radius: 18px;
background: rgba(29, 36, 66, 0.92);
border: 1px solid rgba(126, 136, 180, 0.22);
display: flex;
flex-direction: column;
gap: 20px;
`;
const CardTitle = styled.h2`
margin: 0;
font-size: 1.4rem;
`;
const InfoRow = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px 16px;
border-radius: 12px;
background: rgba(16, 22, 52, 0.6);
border: 1px solid rgba(126, 136, 180, 0.18);
`;
const InfoLabel = styled.span`
font-size: 0.85rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
`;
const InfoValue = styled.span`
font-size: 1.1rem;
color: #e9ebff;
font-weight: 500;
`;
const StatusMessage = styled.div`
padding: 12px;
border-radius: 12px;
background: rgba(12, 18, 42, 0.7);
border: 1px solid rgba(126, 136, 180, 0.24);
color: var(--text-muted);
text-align: center;
`;
const PlaceholderCard = styled(Card)`
align-items: center;
justify-content: center;
min-height: 200px;
color: var(--text-muted);
text-align: center;
`;
export const ParentDetailScreen = () => {
const { parentId } = useParams<{ parentId: string }>();
const navigate = useNavigate();
const [parent, setParent] = useState<Parent | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const loadParent = async () => {
try {
const parents = await listParents();
const foundParent = parents.find((p) => p.id === parentId);
setParent(foundParent || null);
} catch (error) {
console.error("Erreur lors du chargement du parent:", error);
} finally {
setLoading(false);
}
};
void loadParent();
}, [parentId]);
if (loading) {
return (
<Container>
<StatusMessage>Chargement du profil...</StatusMessage>
</Container>
);
}
if (!parent) {
return (
<Container>
<StatusMessage>Parent introuvable</StatusMessage>
</Container>
);
}
const initials = parent.fullName
.split(" ")
.filter(Boolean)
.map((part) => part.charAt(0))
.join("")
.slice(0, 2)
.toUpperCase();
return (
<Container>
<Header>
<BackButton onClick={() => navigate("/profiles")}> Retour</BackButton>
<Avatar
$color={parent.colorHex}
$imageUrl={parent.avatar?.url}
>
{!parent.avatar?.url && initials}
</Avatar>
<HeaderInfo>
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<Title>{parent.fullName}</Title>
<Badge>Parent</Badge>
</div>
{parent.email && <Meta>{parent.email}</Meta>}
{parent.notes && <Meta>{parent.notes}</Meta>}
<Meta style={{ fontSize: "0.85rem", marginTop: "4px" }}>
Profil créé le {new Date(parent.createdAt).toLocaleDateString("fr-FR")}
</Meta>
</HeaderInfo>
</Header>
<Content>
<Card>
<CardTitle>Informations</CardTitle>
<InfoRow>
<InfoLabel>Nom complet</InfoLabel>
<InfoValue>{parent.fullName}</InfoValue>
</InfoRow>
{parent.email && (
<InfoRow>
<InfoLabel>Email</InfoLabel>
<InfoValue>{parent.email}</InfoValue>
</InfoRow>
)}
<InfoRow>
<InfoLabel>Couleur du profil</InfoLabel>
<InfoValue style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<span
style={{
width: "20px",
height: "20px",
borderRadius: "4px",
background: parent.colorHex,
border: `2px solid ${parent.colorHex}`,
}}
/>
{parent.colorHex}
</InfoValue>
</InfoRow>
{parent.notes && (
<InfoRow>
<InfoLabel>Notes</InfoLabel>
<InfoValue>{parent.notes}</InfoValue>
</InfoRow>
)}
</Card>
<PlaceholderCard>
<CardTitle style={{ marginBottom: "12px" }}>Congés personnels</CardTitle>
<Meta>Les congés personnels de {parent.fullName.split(" ")[0]} seront affichés ici.</Meta>
<Meta style={{ fontSize: "0.85rem", marginTop: "8px" }}>
(Fonctionnalité à venir)
</Meta>
</PlaceholderCard>
</Content>
</Container>
);
};

View File

@@ -0,0 +1,416 @@
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
import { useCallback, useEffect, useMemo, useState } from "react";
import styled from "styled-components";
import { useNavigate } from "react-router-dom";
import { useChildren } from "../state/ChildrenContext";
import { ChildCard } from "../components/ChildCard";
import { ChildProfilePanel } from "../components/ChildProfilePanel";
import { ParentProfilePanel } from "../components/ParentProfilePanel";
import { ProfileActionBar } from "../components/ProfileActionBar";
import { PlanningIntegrationDialog } from "../components/PlanningIntegrationDialog";
import { useCalendarIntegrations } from "../state/useCalendarIntegrations";
import { useCalendarOAuthListener } from "../state/useCalendarOAuthListener";
import { consumePendingOAuthState, savePendingOAuthState } from "../utils/calendar-oauth";
import { connectCalendarWithCredentials, deleteGrandParent, deleteParent, listCalendarConnections, listGrandParents, listParents, refreshCalendarConnection, removeCalendarConnection, startCalendarOAuth, uploadPlanning } from "../services/api-client";
const Container = styled.div `
display: flex;
gap: 24px;
align-items: flex-start;
`;
const List = styled.div `
flex: 2;
display: grid;
gap: 24px;
`;
const Title = styled.h1 `
margin: 0 0 12px;
font-size: 1.8rem;
`;
const Description = styled.p `
margin: 0 0 24px;
color: var(--text-muted);
`;
const StatusMessage = styled.div `
padding: 16px;
border-radius: 16px;
border: 1px solid rgba(126, 136, 180, 0.3);
background: rgba(16, 22, 52, 0.7);
color: var(--text-muted);
`;
const Section = styled.section `
display: flex;
flex-direction: column;
gap: 16px;
`;
const SectionHeader = styled.h2 `
margin: 0;
font-size: 1.3rem;
`;
const AdultsGrid = styled.div `
display: grid;
gap: 16px;
`;
const AdultCard = styled.article `
padding: 18px 20px;
border-radius: 16px;
background: rgba(29, 36, 66, 0.92);
border: 1px solid rgba(126, 136, 180, 0.22);
display: flex;
gap: 18px;
align-items: center;
transition: transform 0.2s ease, box-shadow 0.2s ease;
cursor: ${({ $clickable }) => ($clickable ? "pointer" : "default")};
&:hover {
transform: translateY(-2px);
box-shadow: 0 12px 32px ${({ $color }) => `${$color}33`};
}
`;
const AdultAvatar = styled.div `
width: 56px;
height: 56px;
border-radius: 18px;
background: rgba(9, 13, 28, 0.8);
border: 2px solid ${({ $color }) => $color};
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
color: #f4f5ff;
font-size: 1.1rem;
overflow: hidden;
box-shadow: 0 0 12px ${({ $color }) => `${$color}55`};
`;
const AdultAvatarImage = styled.img `
width: 100%;
height: 100%;
object-fit: cover;
`;
const AdultContent = styled.div `
display: flex;
flex-direction: column;
gap: 6px;
`;
const AdultName = styled.span `
font-size: 1.1rem;
font-weight: 600;
`;
const AdultMeta = styled.span `
color: var(--text-muted);
font-size: 0.9rem;
`;
const OAUTH_FALLBACK_URL = {
google: "https://accounts.google.com/o/oauth2/v2/auth",
outlook: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize"
};
const providerLabel = (provider) => (provider === "google" ? "Google" : "Outlook");
const createOAuthState = () => {
try {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
}
catch {
// ignore
}
return `fp_${Math.random().toString(36).slice(2, 10)}`;
};
export const ParentsScreen = () => {
const { children, loading: loadingChildren, error: errorChildren, deleteChild } = useChildren();
const navigate = useNavigate();
const { getConnections, addConnection, replaceConnections, updateConnection, removeConnection } = useCalendarIntegrations();
const [parents, setParents] = useState([]);
const [grandParents, setGrandParents] = useState([]);
const [loadingAdults, setLoadingAdults] = useState(false);
const [errorAdults, setErrorAdults] = useState(null);
const [actionMessage, setActionMessage] = useState(null);
const [editing, setEditing] = useState(null);
const [importing, setImporting] = useState({});
const [connecting, setConnecting] = useState(false);
const [connectionsLoading, setConnectionsLoading] = useState(false);
const [integrationTarget, setIntegrationTarget] = useState(null);
const loadAdults = useCallback(async () => {
setLoadingAdults(true);
setErrorAdults(null);
try {
const [parentsData, grandParentsData] = await Promise.all([listParents(), listGrandParents()]);
setParents(parentsData);
setGrandParents(grandParentsData);
}
catch {
setErrorAdults("Impossible de charger les profils adultes.");
}
finally {
setLoadingAdults(false);
}
}, []);
useEffect(() => {
void loadAdults();
}, [loadAdults]);
const isLoading = loadingChildren || loadingAdults;
const combinedMessage = actionMessage ?? errorChildren ?? errorAdults;
const isEmpty = !isLoading && children.length === 0 && parents.length === 0 && grandParents.length === 0;
const selectedChild = useMemo(() => (editing?.type === "child" ? children.find((child) => child.id === editing.id) ?? null : null), [children, editing]);
const selectedAdult = useMemo(() => {
if (!editing)
return null;
if (editing.type === "parent") {
return parents.find((parent) => parent.id === editing.id) ?? null;
}
if (editing.type === "grandparent") {
return grandParents.find((grandParent) => grandParent.id === editing.id) ?? null;
}
return null;
}, [editing, parents, grandParents]);
const showToast = useCallback((success, message) => {
try {
window.__fp_toast?.(success, message);
}
catch {
if (!success && typeof window !== "undefined" && typeof window.alert === "function") {
window.alert(message);
}
}
}, []);
const loadConnections = useCallback(async (profileId) => {
if (!profileId)
return;
setConnectionsLoading(true);
try {
const remote = await listCalendarConnections(profileId);
replaceConnections(profileId, remote);
}
catch {
// backend indisponible : on conserve l'etat local
}
finally {
setConnectionsLoading(false);
}
}, [replaceConnections]);
const handleDeleteChild = async (childId) => {
const confirmed = window.confirm("Supprimer cet enfant ? L'action est definitive.");
if (!confirmed)
return;
await deleteChild(childId);
if (editing?.type === "child" && editing.id === childId) {
setEditing(null);
}
};
const handleDeleteAdult = async (id, type) => {
setActionMessage(null);
const label = type === "parent" ? "ce parent" : "ce grand-parent";
const confirmed = window.confirm(`Es-tu certain de vouloir supprimer ${label} ? L'action est definitive.`);
if (!confirmed)
return;
try {
if (type === "parent") {
await deleteParent(id);
}
else {
await deleteGrandParent(id);
}
if (editing && editing.id === id && editing.type === type) {
setEditing(null);
}
void loadAdults();
}
catch {
setActionMessage("Suppression impossible pour le moment. Reessaie plus tard.");
}
};
const handleImportPlanning = async (profileId, file) => {
setImporting((prev) => ({ ...prev, [profileId]: true }));
setActionMessage(null);
try {
await uploadPlanning(profileId, file);
showToast(true, "Analyse du planning lancee.");
}
catch {
showToast(false, "Echec de l'import du planning.");
}
finally {
setImporting((prev) => ({ ...prev, [profileId]: false }));
}
};
const handleViewAdultPlanning = (id, type) => {
navigate(`/profiles/${type}/${id}/planning`);
};
const openIntegration = (id, name, kind) => {
setIntegrationTarget({ id, name, kind });
setActionMessage(null);
};
const handleManualCalendarConnect = async (provider, payload) => {
if (!integrationTarget)
return;
setConnecting(true);
try {
const connectionId = addConnection(integrationTarget.id, provider, {
email: payload.email,
label: payload.label ?? payload.email,
status: "pending",
shareWithFamily: payload.shareWithFamily
});
try {
const response = await connectCalendarWithCredentials(provider, {
profileId: integrationTarget.id,
email: payload.email,
password: payload.password,
label: payload.label,
shareWithFamily: payload.shareWithFamily
});
const connection = response?.connection;
if (connection) {
updateConnection(integrationTarget.id, connectionId, {
status: connection.status,
email: connection.email,
label: connection.label ?? connection.email,
lastSyncedAt: connection.lastSyncedAt,
shareWithFamily: connection.shareWithFamily,
scopes: connection.scopes,
createdAt: connection.createdAt
});
}
else {
updateConnection(integrationTarget.id, connectionId, {
status: "connected",
lastSyncedAt: new Date().toLocaleString()
});
}
}
catch {
// backend indisponible : on restera en pending jusqu'a la synchro
}
showToast(true, "Connexion agenda en cours. Le statut passera a \"connecte\" apres synchronisation.");
await loadConnections(integrationTarget.id);
}
catch {
setActionMessage("Connexion calendrier impossible pour le moment.");
}
finally {
setConnecting(false);
}
};
const handleRefreshConnection = async (connectionId) => {
if (!integrationTarget)
return;
try {
updateConnection(integrationTarget.id, connectionId, { status: "pending" });
const result = await refreshCalendarConnection(integrationTarget.id, connectionId);
updateConnection(integrationTarget.id, connectionId, {
status: result.status,
lastSyncedAt: result.lastSyncedAt ?? new Date().toLocaleString()
});
showToast(true, "Synchronisation relancee.");
}
catch {
updateConnection(integrationTarget.id, connectionId, { status: "error" });
showToast(false, "Impossible de relancer la synchronisation pour le moment.");
}
};
const handleStartOAuth = async (provider) => {
if (!integrationTarget)
return;
const state = createOAuthState();
const profileId = integrationTarget.id;
const fallbackUrl = (() => {
try {
const url = new URL(OAUTH_FALLBACK_URL[provider]);
url.searchParams.set("state", state);
return url.toString();
}
catch {
return `${OAUTH_FALLBACK_URL[provider]}?state=${encodeURIComponent(state)}`;
}
})();
let popupUrl = fallbackUrl;
try {
const response = await startCalendarOAuth(provider, { profileId, state });
if (response) {
popupUrl = response.authUrl ?? response.url ?? response.authorizeUrl ?? fallbackUrl;
}
}
catch {
// fallback automatique
}
const connectionId = addConnection(profileId, provider, {
email: `${providerLabel(provider)} OAuth`,
label: `Connexion OAuth (${new Date().toLocaleDateString()})`,
status: "pending"
});
savePendingOAuthState({ state, profileId, provider, connectionId });
const popupFeatures = "width=520,height=720,noopener=yes";
const popup = window.open(popupUrl, "fp-calendar-oauth", popupFeatures);
popup?.focus();
showToast(true, "Authentification ouverte. Termine le parcours dans la fenetre dediee.");
};
const handleDisconnect = async (connectionId) => {
if (!integrationTarget)
return;
try {
await removeCalendarConnection(integrationTarget.id, connectionId);
}
catch {
// ignore backend failure
}
removeConnection(integrationTarget.id, connectionId);
};
useEffect(() => {
if (integrationTarget) {
void loadConnections(integrationTarget.id);
}
}, [integrationTarget, loadConnections]);
useCalendarOAuthListener(useCallback((message) => {
const pending = consumePendingOAuthState(message.state);
if (!pending)
return;
const resolvedProvider = message.provider ?? pending.provider;
if (message.success) {
updateConnection(pending.profileId, pending.connectionId, {
status: "connected",
email: message.email ?? `${providerLabel(resolvedProvider)} OAuth`,
label: message.label ??
message.email ??
`${providerLabel(resolvedProvider)} OAuth`,
lastSyncedAt: new Date().toLocaleString()
});
showToast(true, "Agenda connecte avec succes.");
void loadConnections(pending.profileId);
}
else {
updateConnection(pending.profileId, pending.connectionId, { status: "error" });
showToast(false, message.error ?? "Connexion calendrier annulee.");
}
}, [loadConnections, showToast, updateConnection]));
return (_jsxs(_Fragment, { children: [_jsx(Title, { children: "Profils" }), _jsx(Description, { children: "Gere l'ensemble des profils : enfants, parents et grands-parents." }), _jsxs(Container, { children: [_jsxs(List, { children: [isLoading ? _jsx(StatusMessage, { children: "Chargement des informations..." }) : null, combinedMessage ? _jsx(StatusMessage, { children: combinedMessage }) : null, isEmpty ? _jsx(StatusMessage, { children: "Aucun profil enregistre pour le moment. Ajoute un nouveau profil !" }) : null, _jsxs(Section, { children: [_jsx(SectionHeader, { children: "Enfants" }), children.length === 0 ? (_jsx(StatusMessage, { children: "Aucun enfant enregistre." })) : (children.map((child) => (_jsx(ChildCard, { child: child, onDelete: handleDeleteChild, onEdit: (id) => setEditing({ type: "child", id }), onViewProfile: (id) => navigate(`/profiles/child/${id}`), onViewPlanning: (id) => navigate(`/children/${id}/planning`), onOpenPlanningCenter: (item) => openIntegration(item.id, item.fullName, "child"), importing: !!importing[child.id], connectionsCount: getConnections(child.id).length }, child.id))))] }), _jsxs(Section, { children: [_jsx(SectionHeader, { children: "Parents" }), parents.length === 0 ? (_jsx(StatusMessage, { children: "Aucun parent enregistre." })) : (_jsx(AdultsGrid, { children: parents.map((parent) => {
const initials = parent.fullName
.split(" ")
.map((part) => part.charAt(0))
.join("")
.slice(0, 2)
.toUpperCase();
const handleParentCardClick = (e) => {
const target = e.target;
if (target.closest("button"))
return;
navigate(`/profiles/parent/${parent.id}`);
};
return (_jsxs(AdultCard, { "$color": parent.colorHex, "$clickable": true, onClick: handleParentCardClick, children: [_jsx(AdultAvatar, { "$color": parent.colorHex, children: parent.avatar ? (_jsx(AdultAvatarImage, { src: parent.avatar.url, alt: parent.avatar.name ?? `Portrait de ${parent.fullName}` })) : (initials) }), _jsxs(AdultContent, { children: [_jsx(AdultName, { children: parent.fullName }), parent.email ? _jsx(AdultMeta, { children: parent.email }) : null, parent.notes ? _jsx(AdultMeta, { children: parent.notes }) : null] }), _jsx(ProfileActionBar, { onView: () => handleViewAdultPlanning(parent.id, "parent"), onOpenPlanningCenter: () => openIntegration(parent.id, parent.fullName, "parent"), onEdit: () => setEditing({ type: "parent", id: parent.id }), onDelete: () => handleDeleteAdult(parent.id, "parent"), importing: !!importing[parent.id], connectionsCount: getConnections(parent.id).length })] }, parent.id));
}) }))] }), _jsxs(Section, { children: [_jsx(SectionHeader, { children: "Grands-parents" }), grandParents.length === 0 ? (_jsx(StatusMessage, { children: "Aucun grand-parent enregistre." })) : (_jsx(AdultsGrid, { children: grandParents.map((grandParent) => {
const initials = grandParent.fullName
.split(" ")
.map((part) => part.charAt(0))
.join("")
.slice(0, 2)
.toUpperCase();
const handleGrandParentCardClick = (e) => {
const target = e.target;
if (target.closest("button"))
return;
navigate(`/profiles/grandparent/${grandParent.id}`);
};
return (_jsxs(AdultCard, { "$color": grandParent.colorHex, "$clickable": true, onClick: handleGrandParentCardClick, children: [_jsx(AdultAvatar, { "$color": grandParent.colorHex, children: grandParent.avatar ? (_jsx(AdultAvatarImage, { src: grandParent.avatar.url, alt: grandParent.avatar.name ?? `Portrait de ${grandParent.fullName}` })) : (initials) }), _jsxs(AdultContent, { children: [_jsx(AdultName, { children: grandParent.fullName }), grandParent.email ? _jsx(AdultMeta, { children: grandParent.email }) : null, grandParent.notes ? _jsx(AdultMeta, { children: grandParent.notes }) : null] }), _jsx(ProfileActionBar, { onView: () => handleViewAdultPlanning(grandParent.id, "grandparent"), onOpenPlanningCenter: () => openIntegration(grandParent.id, grandParent.fullName, "grandparent"), onEdit: () => setEditing({ type: "grandparent", id: grandParent.id }), onDelete: () => handleDeleteAdult(grandParent.id, "grandparent"), importing: !!importing[grandParent.id], connectionsCount: getConnections(grandParent.id).length })] }, grandParent.id));
}) }))] })] }), editing?.type === "child" && selectedChild ? (_jsx(ChildProfilePanel, { mode: "edit", child: selectedChild, onCancel: () => setEditing(null) })) : null, editing && editing.type !== "child" && selectedAdult ? (_jsx(ParentProfilePanel, { mode: "edit", parent: selectedAdult, kind: editing.type, onCancel: () => {
setEditing(null);
void loadAdults();
} })) : null] }), _jsx(PlanningIntegrationDialog, { open: integrationTarget !== null, profileName: integrationTarget?.name ?? "", onClose: () => setIntegrationTarget(null), onImportFile: integrationTarget ? (file) => handleImportPlanning(integrationTarget.id, file) : undefined, onConnectManual: integrationTarget ? handleManualCalendarConnect : undefined, onStartOAuth: integrationTarget ? handleStartOAuth : undefined, onRefreshConnection: integrationTarget ? handleRefreshConnection : undefined, onReloadConnections: integrationTarget ? () => loadConnections(integrationTarget.id) : undefined, connections: integrationTarget ? getConnections(integrationTarget.id) : [], onDisconnect: integrationTarget ? handleDisconnect : undefined, importing: integrationTarget ? !!importing[integrationTarget.id] : false, connecting: connecting, connectionsLoading: connectionsLoading })] }));
};

View File

@@ -0,0 +1,626 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import styled from "styled-components";
import { useNavigate } from "react-router-dom";
import { useChildren } from "../state/ChildrenContext";
import { ChildCard } from "../components/ChildCard";
import { ChildProfilePanel } from "../components/ChildProfilePanel";
import { ParentProfilePanel } from "../components/ParentProfilePanel";
import { ProfileActionBar } from "../components/ProfileActionBar";
import { PlanningIntegrationDialog } from "../components/PlanningIntegrationDialog";
import { useCalendarIntegrations } from "../state/useCalendarIntegrations";
import { useCalendarOAuthListener } from "../state/useCalendarOAuthListener";
import { consumePendingOAuthState, savePendingOAuthState } from "../utils/calendar-oauth";
import { CalendarProvider } from "../types/calendar";
import {
connectCalendarWithCredentials,
deleteGrandParent,
deleteParent,
listCalendarConnections,
listGrandParents,
listParents,
refreshCalendarConnection,
removeCalendarConnection,
startCalendarOAuth,
uploadPlanning
} from "../services/api-client";
type AdultProfile = {
id: string;
fullName: string;
colorHex: string;
email?: string;
notes?: string;
avatar?: { kind: "preset" | "custom"; url: string; name?: string };
};
type EditingTarget =
| { type: "child"; id: string }
| { type: "parent"; id: string }
| { type: "grandparent"; id: string }
| null;
type IntegrationTarget = {
id: string;
name: string;
kind: "child" | "parent" | "grandparent";
};
const Container = styled.div`
display: flex;
gap: 24px;
align-items: flex-start;
`;
const List = styled.div`
flex: 2;
display: grid;
gap: 24px;
`;
const Title = styled.h1`
margin: 0 0 12px;
font-size: 1.8rem;
`;
const Description = styled.p`
margin: 0 0 24px;
color: var(--text-muted);
`;
const StatusMessage = styled.div`
padding: 16px;
border-radius: 16px;
border: 1px solid rgba(126, 136, 180, 0.3);
background: rgba(16, 22, 52, 0.7);
color: var(--text-muted);
`;
const Section = styled.section`
display: flex;
flex-direction: column;
gap: 16px;
`;
const SectionHeader = styled.h2`
margin: 0;
font-size: 1.3rem;
`;
const AdultsGrid = styled.div`
display: grid;
gap: 16px;
`;
const AdultCard = styled.article<{ $color: string; $clickable?: boolean }>`
padding: 18px 20px;
border-radius: 16px;
background: rgba(29, 36, 66, 0.92);
border: 1px solid rgba(126, 136, 180, 0.22);
display: flex;
gap: 18px;
align-items: center;
transition: transform 0.2s ease, box-shadow 0.2s ease;
cursor: ${({ $clickable }) => ($clickable ? "pointer" : "default")};
&:hover {
transform: translateY(-2px);
box-shadow: 0 12px 32px ${({ $color }) => `${$color}33`};
}
`;
const AdultAvatar = styled.div<{ $color: string }>`
width: 56px;
height: 56px;
border-radius: 18px;
background: rgba(9, 13, 28, 0.8);
border: 2px solid ${({ $color }) => $color};
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
color: #f4f5ff;
font-size: 1.1rem;
overflow: hidden;
box-shadow: 0 0 12px ${({ $color }) => `${$color}55`};
`;
const AdultAvatarImage = styled.img`
width: 100%;
height: 100%;
object-fit: cover;
`;
const AdultContent = styled.div`
display: flex;
flex-direction: column;
gap: 6px;
`;
const AdultName = styled.span`
font-size: 1.1rem;
font-weight: 600;
`;
const AdultMeta = styled.span`
color: var(--text-muted);
font-size: 0.9rem;
`;
const OAUTH_FALLBACK_URL: Record<CalendarProvider, string> = {
google: "https://accounts.google.com/o/oauth2/v2/auth",
outlook: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize"
};
const providerLabel = (provider: CalendarProvider) => (provider === "google" ? "Google" : "Outlook");
const createOAuthState = () => {
try {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
} catch {
// ignore
}
return `fp_${Math.random().toString(36).slice(2, 10)}`;
};
export const ParentsScreen = () => {
const { children, loading: loadingChildren, error: errorChildren, deleteChild } = useChildren();
const navigate = useNavigate();
const { getConnections, addConnection, replaceConnections, updateConnection, removeConnection } =
useCalendarIntegrations();
const [parents, setParents] = useState<AdultProfile[]>([]);
const [grandParents, setGrandParents] = useState<AdultProfile[]>([]);
const [loadingAdults, setLoadingAdults] = useState<boolean>(false);
const [errorAdults, setErrorAdults] = useState<string | null>(null);
const [actionMessage, setActionMessage] = useState<string | null>(null);
const [editing, setEditing] = useState<EditingTarget>(null);
const [importing, setImporting] = useState<Record<string, boolean>>({});
const [connecting, setConnecting] = useState(false);
const [connectionsLoading, setConnectionsLoading] = useState(false);
const [integrationTarget, setIntegrationTarget] = useState<IntegrationTarget | null>(null);
const loadAdults = useCallback(async () => {
setLoadingAdults(true);
setErrorAdults(null);
try {
const [parentsData, grandParentsData] = await Promise.all([listParents(), listGrandParents()]);
setParents(parentsData as AdultProfile[]);
setGrandParents(grandParentsData as AdultProfile[]);
} catch {
setErrorAdults("Impossible de charger les profils adultes.");
} finally {
setLoadingAdults(false);
}
}, []);
useEffect(() => {
void loadAdults();
}, [loadAdults]);
const isLoading = loadingChildren || loadingAdults;
const combinedMessage = actionMessage ?? errorChildren ?? errorAdults;
const isEmpty = !isLoading && children.length === 0 && parents.length === 0 && grandParents.length === 0;
const selectedChild = useMemo(
() => (editing?.type === "child" ? children.find((child) => child.id === editing.id) ?? null : null),
[children, editing]
);
const selectedAdult = useMemo(() => {
if (!editing) return null;
if (editing.type === "parent") {
return parents.find((parent) => parent.id === editing.id) ?? null;
}
if (editing.type === "grandparent") {
return grandParents.find((grandParent) => grandParent.id === editing.id) ?? null;
}
return null;
}, [editing, parents, grandParents]);
const showToast = useCallback((success: boolean, message: string) => {
try {
(window as any).__fp_toast?.(success, message);
} catch {
if (!success && typeof window !== "undefined" && typeof window.alert === "function") {
window.alert(message);
}
}
}, []);
const loadConnections = useCallback(
async (profileId: string) => {
if (!profileId) return;
setConnectionsLoading(true);
try {
const remote = await listCalendarConnections(profileId);
replaceConnections(profileId, remote);
} catch {
// backend indisponible : on conserve l'etat local
} finally {
setConnectionsLoading(false);
}
},
[replaceConnections]
);
const handleDeleteChild = async (childId: string) => {
const confirmed = window.confirm("Supprimer cet enfant ? L'action est definitive.");
if (!confirmed) return;
await deleteChild(childId);
if (editing?.type === "child" && editing.id === childId) {
setEditing(null);
}
};
const handleDeleteAdult = async (id: string, type: "parent" | "grandparent") => {
setActionMessage(null);
const label = type === "parent" ? "ce parent" : "ce grand-parent";
const confirmed = window.confirm(`Es-tu certain de vouloir supprimer ${label} ? L'action est definitive.`);
if (!confirmed) return;
try {
if (type === "parent") {
await deleteParent(id);
} else {
await deleteGrandParent(id);
}
if (editing && editing.id === id && editing.type === type) {
setEditing(null);
}
void loadAdults();
} catch {
setActionMessage("Suppression impossible pour le moment. Reessaie plus tard.");
}
};
const handleImportPlanning = async (profileId: string, file: File) => {
setImporting((prev) => ({ ...prev, [profileId]: true }));
setActionMessage(null);
try {
await uploadPlanning(profileId, file);
showToast(true, "Analyse du planning lancee.");
} catch {
showToast(false, "Echec de l'import du planning.");
} finally {
setImporting((prev) => ({ ...prev, [profileId]: false }));
}
};
const handleViewAdultPlanning = (id: string, type: "parent" | "grandparent") => {
navigate(`/profiles/${type}/${id}/planning`);
};
const openIntegration = (id: string, name: string, kind: IntegrationTarget["kind"]) => {
setIntegrationTarget({ id, name, kind });
setActionMessage(null);
};
const handleManualCalendarConnect = async (
provider: CalendarProvider,
payload: { email: string; password: string; label?: string; shareWithFamily: boolean }
) => {
if (!integrationTarget) return;
setConnecting(true);
try {
const connectionId = addConnection(integrationTarget.id, provider, {
email: payload.email,
label: payload.label ?? payload.email,
status: "pending",
shareWithFamily: payload.shareWithFamily
});
try {
const response = await connectCalendarWithCredentials(provider, {
profileId: integrationTarget.id,
email: payload.email,
password: payload.password,
label: payload.label,
shareWithFamily: payload.shareWithFamily
});
const connection = response?.connection;
if (connection) {
updateConnection(integrationTarget.id, connectionId, {
status: connection.status,
email: connection.email,
label: connection.label ?? connection.email,
lastSyncedAt: connection.lastSyncedAt,
shareWithFamily: connection.shareWithFamily,
scopes: connection.scopes,
createdAt: connection.createdAt
});
} else {
updateConnection(integrationTarget.id, connectionId, {
status: "connected",
lastSyncedAt: new Date().toLocaleString()
});
}
} catch {
// backend indisponible : on restera en pending jusqu'a la synchro
}
showToast(true, "Connexion agenda en cours. Le statut passera a \"connecte\" apres synchronisation.");
await loadConnections(integrationTarget.id);
} catch {
setActionMessage("Connexion calendrier impossible pour le moment.");
} finally {
setConnecting(false);
}
};
const handleRefreshConnection = async (connectionId: string) => {
if (!integrationTarget) return;
try {
updateConnection(integrationTarget.id, connectionId, { status: "pending" });
const result = await refreshCalendarConnection(integrationTarget.id, connectionId);
updateConnection(integrationTarget.id, connectionId, {
status: result.status,
lastSyncedAt: result.lastSyncedAt ?? new Date().toLocaleString()
});
showToast(true, "Synchronisation relancee.");
} catch {
updateConnection(integrationTarget.id, connectionId, { status: "error" });
showToast(false, "Impossible de relancer la synchronisation pour le moment.");
}
};
const handleStartOAuth = async (provider: CalendarProvider) => {
if (!integrationTarget) return;
const state = createOAuthState();
const profileId = integrationTarget.id;
const fallbackUrl = (() => {
try {
const url = new URL(OAUTH_FALLBACK_URL[provider]);
url.searchParams.set("state", state);
return url.toString();
} catch {
return `${OAUTH_FALLBACK_URL[provider]}?state=${encodeURIComponent(state)}`;
}
})();
let popupUrl = fallbackUrl;
try {
const response = await startCalendarOAuth(provider, { profileId, state });
if (response) {
popupUrl = response.authUrl ?? response.url ?? response.authorizeUrl ?? fallbackUrl;
}
} catch {
// fallback automatique
}
const connectionId = addConnection(profileId, provider, {
email: `${providerLabel(provider)} OAuth`,
label: `Connexion OAuth (${new Date().toLocaleDateString()})`,
status: "pending"
});
savePendingOAuthState({ state, profileId, provider, connectionId });
const popupFeatures = "width=520,height=720,noopener=yes";
const popup = window.open(popupUrl, "fp-calendar-oauth", popupFeatures);
popup?.focus();
showToast(true, "Authentification ouverte. Termine le parcours dans la fenetre dediee.");
};
const handleDisconnect = async (connectionId: string) => {
if (!integrationTarget) return;
try {
await removeCalendarConnection(integrationTarget.id, connectionId);
} catch {
// ignore backend failure
}
removeConnection(integrationTarget.id, connectionId);
};
useEffect(() => {
if (integrationTarget) {
void loadConnections(integrationTarget.id);
}
}, [integrationTarget, loadConnections]);
useCalendarOAuthListener(
useCallback(
(message) => {
const pending = consumePendingOAuthState(message.state);
if (!pending) return;
const resolvedProvider = message.provider ?? pending.provider;
if (message.success) {
updateConnection(pending.profileId, pending.connectionId, {
status: "connected",
email: message.email ?? `${providerLabel(resolvedProvider)} OAuth`,
label:
message.label ??
message.email ??
`${providerLabel(resolvedProvider)} OAuth`,
lastSyncedAt: new Date().toLocaleString()
});
showToast(true, "Agenda connecte avec succes.");
void loadConnections(pending.profileId);
} else {
updateConnection(pending.profileId, pending.connectionId, { status: "error" });
showToast(false, message.error ?? "Connexion calendrier annulee.");
}
},
[loadConnections, showToast, updateConnection]
)
);
return (
<>
<Title>Profils</Title>
<Description>Gere l'ensemble des profils : enfants, parents et grands-parents.</Description>
<Container>
<List>
{isLoading ? <StatusMessage>Chargement des informations...</StatusMessage> : null}
{combinedMessage ? <StatusMessage>{combinedMessage}</StatusMessage> : null}
{isEmpty ? <StatusMessage>Aucun profil enregistre pour le moment. Ajoute un nouveau profil !</StatusMessage> : null}
<Section>
<SectionHeader>Enfants</SectionHeader>
{children.length === 0 ? (
<StatusMessage>Aucun enfant enregistre.</StatusMessage>
) : (
children.map((child) => (
<ChildCard
key={child.id}
child={child}
onDelete={handleDeleteChild}
onEdit={(id) => setEditing({ type: "child", id })}
onViewProfile={(id) => navigate(`/profiles/child/${id}`)}
onViewPlanning={(id) => navigate(`/children/${id}/planning`)}
onOpenPlanningCenter={(item) => openIntegration(item.id, item.fullName, "child")}
importing={!!importing[child.id]}
connectionsCount={getConnections(child.id).length}
/>
))
)}
</Section>
<Section>
<SectionHeader>Parents</SectionHeader>
{parents.length === 0 ? (
<StatusMessage>Aucun parent enregistre.</StatusMessage>
) : (
<AdultsGrid>
{parents.map((parent) => {
const initials = parent.fullName
.split(" ")
.map((part) => part.charAt(0))
.join("")
.slice(0, 2)
.toUpperCase();
const handleParentCardClick = (e: React.MouseEvent) => {
const target = e.target as HTMLElement;
if (target.closest("button")) return;
navigate(`/profiles/parent/${parent.id}`);
};
return (
<AdultCard
key={parent.id}
$color={parent.colorHex}
$clickable={true}
onClick={handleParentCardClick}
>
<AdultAvatar $color={parent.colorHex}>
{parent.avatar ? (
<AdultAvatarImage
src={parent.avatar.url}
alt={parent.avatar.name ?? `Portrait de ${parent.fullName}`}
/>
) : (
initials
)}
</AdultAvatar>
<AdultContent>
<AdultName>{parent.fullName}</AdultName>
{parent.email ? <AdultMeta>{parent.email}</AdultMeta> : null}
{parent.notes ? <AdultMeta>{parent.notes}</AdultMeta> : null}
</AdultContent>
<ProfileActionBar
onView={() => handleViewAdultPlanning(parent.id, "parent")}
onOpenPlanningCenter={() => openIntegration(parent.id, parent.fullName, "parent")}
onEdit={() => setEditing({ type: "parent", id: parent.id })}
onDelete={() => handleDeleteAdult(parent.id, "parent")}
importing={!!importing[parent.id]}
connectionsCount={getConnections(parent.id).length}
/>
</AdultCard>
);
})}
</AdultsGrid>
)}
</Section>
<Section>
<SectionHeader>Grands-parents</SectionHeader>
{grandParents.length === 0 ? (
<StatusMessage>Aucun grand-parent enregistre.</StatusMessage>
) : (
<AdultsGrid>
{grandParents.map((grandParent) => {
const initials = grandParent.fullName
.split(" ")
.map((part) => part.charAt(0))
.join("")
.slice(0, 2)
.toUpperCase();
const handleGrandParentCardClick = (e: React.MouseEvent) => {
const target = e.target as HTMLElement;
if (target.closest("button")) return;
navigate(`/profiles/grandparent/${grandParent.id}`);
};
return (
<AdultCard
key={grandParent.id}
$color={grandParent.colorHex}
$clickable={true}
onClick={handleGrandParentCardClick}
>
<AdultAvatar $color={grandParent.colorHex}>
{grandParent.avatar ? (
<AdultAvatarImage
src={grandParent.avatar.url}
alt={grandParent.avatar.name ?? `Portrait de ${grandParent.fullName}`}
/>
) : (
initials
)}
</AdultAvatar>
<AdultContent>
<AdultName>{grandParent.fullName}</AdultName>
{grandParent.email ? <AdultMeta>{grandParent.email}</AdultMeta> : null}
{grandParent.notes ? <AdultMeta>{grandParent.notes}</AdultMeta> : null}
</AdultContent>
<ProfileActionBar
onView={() => handleViewAdultPlanning(grandParent.id, "grandparent")}
onOpenPlanningCenter={() => openIntegration(grandParent.id, grandParent.fullName, "grandparent")}
onEdit={() => setEditing({ type: "grandparent", id: grandParent.id })}
onDelete={() => handleDeleteAdult(grandParent.id, "grandparent")}
importing={!!importing[grandParent.id]}
connectionsCount={getConnections(grandParent.id).length}
/>
</AdultCard>
);
})}
</AdultsGrid>
)}
</Section>
</List>
{editing?.type === "child" && selectedChild ? (
<ChildProfilePanel mode="edit" child={selectedChild} onCancel={() => setEditing(null)} />
) : null}
{editing && editing.type !== "child" && selectedAdult ? (
<ParentProfilePanel
mode="edit"
parent={selectedAdult}
kind={editing.type}
onCancel={() => {
setEditing(null);
void loadAdults();
}}
/>
) : null}
</Container>
<PlanningIntegrationDialog
open={integrationTarget !== null}
profileName={integrationTarget?.name ?? ""}
onClose={() => setIntegrationTarget(null)}
onImportFile={
integrationTarget ? (file) => handleImportPlanning(integrationTarget.id, file) : undefined
}
onConnectManual={integrationTarget ? handleManualCalendarConnect : undefined}
onStartOAuth={integrationTarget ? handleStartOAuth : undefined}
onRefreshConnection={integrationTarget ? handleRefreshConnection : undefined}
onReloadConnections={integrationTarget ? () => loadConnections(integrationTarget.id) : undefined}
connections={integrationTarget ? getConnections(integrationTarget.id) : []}
onDisconnect={integrationTarget ? handleDisconnect : undefined}
importing={integrationTarget ? !!importing[integrationTarget.id] : false}
connecting={connecting}
connectionsLoading={connectionsLoading}
/>
</>
);
};

View File

@@ -0,0 +1,86 @@
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
import { useMemo } from "react";
import { useNavigate, useParams } from "react-router-dom";
import styled from "styled-components";
const Container = styled.div `
display: flex;
flex-direction: column;
gap: 24px;
max-width: 720px;
`;
const Title = styled.h1 `
margin: 0;
font-size: 2rem;
`;
const Subtitle = styled.p `
margin: 0;
color: var(--text-muted);
line-height: 1.6;
`;
const Card = styled.div `
padding: 24px;
border-radius: 18px;
background: rgba(21, 27, 54, 0.9);
border: 1px solid rgba(126, 136, 180, 0.3);
display: flex;
flex-direction: column;
gap: 14px;
`;
const CardTitle = styled.span `
font-weight: 600;
font-size: 1.05rem;
`;
const CardList = styled.ul `
margin: 0;
padding-left: 20px;
color: var(--text-muted);
line-height: 1.6;
`;
const Actions = styled.div `
display: flex;
flex-wrap: wrap;
gap: 12px;
`;
const PrimaryButton = styled.button `
padding: 12px 18px;
border-radius: 12px;
background: rgba(85, 98, 255, 0.2);
border: 1px solid rgba(85, 98, 255, 0.5);
color: #f4f5ff;
font-weight: 600;
cursor: pointer;
transition: background 0.2s ease;
&:hover {
background: rgba(85, 98, 255, 0.32);
}
`;
const SecondaryButton = styled(PrimaryButton) `
background: rgba(41, 55, 120, 0.25);
border-color: rgba(126, 136, 180, 0.4);
&:hover {
background: rgba(41, 55, 120, 0.35);
}
`;
export const PersonPlanningScreen = () => {
const navigate = useNavigate();
const { profileType, profileId } = useParams();
const profileLabel = useMemo(() => {
switch (profileType) {
case "parent":
return "ce parent";
case "grandparent":
return "ce grand-parent";
case "child":
return "cet enfant";
default:
return "ce profil";
}
}, [profileType]);
return (_jsxs(Container, { children: [_jsxs(Title, { children: ["Planning \u2014 ", profileLabel] }), _jsxs(Subtitle, { children: ["La consultation du planning pour ", profileLabel, " arrive tr\u00E8s bient\u00F4t. Nous finalisons l\u2019int\u00E9gration directe des agendas Outlook et Gmail afin de synchroniser automatiquement les \u00E9v\u00E9nements d\u00E9tect\u00E9s lors de l\u2019import."] }), _jsxs(Card, { children: [_jsx(CardTitle, { children: "\u00C0 venir dans la prochaine mise \u00E0 jour" }), _jsxs(CardList, { children: [_jsx("li", { children: "Connexion s\u00E9curis\u00E9e \u00E0 Outlook et Gmail pour importer les disponibilit\u00E9s en temps r\u00E9el." }), _jsx("li", { children: "Association des cr\u00E9neaux d\u00E9tect\u00E9s lors de l\u2019analyse de fichiers aux calendriers connect\u00E9s." }), _jsx("li", { children: "Centre de contr\u00F4le pour g\u00E9rer les autorisations accord\u00E9es \u00E0 chaque profil." })] })] }), _jsxs(Card, { children: [_jsx(CardTitle, { children: "En attendant\u2026" }), _jsxs(CardList, { children: [_jsx("li", { children: "Continue d\u2019importer un fichier pour pr\u00E9parer les plannings d\u00E9taill\u00E9s." }), _jsx("li", { children: "Centralise les informations cl\u00E9s dans la fiche profil pour garder le contexte accessible." }), _jsx("li", { children: "Utilise la vue calendrier mensuelle pour partager la vision d\u2019ensemble avec toute la famille." })] })] }), _jsxs(Actions, { children: [_jsx(PrimaryButton, { type: "button", onClick: () => navigate("/profiles"), children: "Retour aux profils" }), _jsx(SecondaryButton, { type: "button", onClick: () => navigate("/calendar/month"), children: "Ouvrir le calendrier mensuel" }), _jsx(SecondaryButton, { type: "button", onClick: () => navigate(-1), children: "Revenir \u00E0 l\u2019\u00E9cran pr\u00E9c\u00E9dent" }), _jsx(SecondaryButton, { type: "button", onClick: () => {
if (typeof window !== "undefined") {
window.scrollTo({ top: 0, behavior: "smooth" });
}
}, children: "Remonter en haut de page" })] }), profileId ? (_jsxs(Subtitle, { style: { fontSize: "0.85rem" }, children: ["Identifiant technique : ", _jsx("code", { children: profileId })] })) : null] }));
};

View File

@@ -0,0 +1,147 @@
import { useMemo } from "react";
import { useNavigate, useParams } from "react-router-dom";
import styled from "styled-components";
const Container = styled.div`
display: flex;
flex-direction: column;
gap: 24px;
max-width: 720px;
`;
const Title = styled.h1`
margin: 0;
font-size: 2rem;
`;
const Subtitle = styled.p`
margin: 0;
color: var(--text-muted);
line-height: 1.6;
`;
const Card = styled.div`
padding: 24px;
border-radius: 18px;
background: rgba(21, 27, 54, 0.9);
border: 1px solid rgba(126, 136, 180, 0.3);
display: flex;
flex-direction: column;
gap: 14px;
`;
const CardTitle = styled.span`
font-weight: 600;
font-size: 1.05rem;
`;
const CardList = styled.ul`
margin: 0;
padding-left: 20px;
color: var(--text-muted);
line-height: 1.6;
`;
const Actions = styled.div`
display: flex;
flex-wrap: wrap;
gap: 12px;
`;
const PrimaryButton = styled.button`
padding: 12px 18px;
border-radius: 12px;
background: rgba(85, 98, 255, 0.2);
border: 1px solid rgba(85, 98, 255, 0.5);
color: #f4f5ff;
font-weight: 600;
cursor: pointer;
transition: background 0.2s ease;
&:hover {
background: rgba(85, 98, 255, 0.32);
}
`;
const SecondaryButton = styled(PrimaryButton)`
background: rgba(41, 55, 120, 0.25);
border-color: rgba(126, 136, 180, 0.4);
&:hover {
background: rgba(41, 55, 120, 0.35);
}
`;
export const PersonPlanningScreen = () => {
const navigate = useNavigate();
const { profileType, profileId } = useParams<{ profileType?: string; profileId?: string }>();
const profileLabel = useMemo(() => {
switch (profileType) {
case "parent":
return "ce parent";
case "grandparent":
return "ce grand-parent";
case "child":
return "cet enfant";
default:
return "ce profil";
}
}, [profileType]);
return (
<Container>
<Title>Planning {profileLabel}</Title>
<Subtitle>
La consultation du planning pour {profileLabel} arrive très bientôt. Nous finalisons lintégration directe des
agendas Outlook et Gmail afin de synchroniser automatiquement les événements détectés lors de limport.
</Subtitle>
<Card>
<CardTitle>À venir dans la prochaine mise à jour</CardTitle>
<CardList>
<li>Connexion sécurisée à Outlook et Gmail pour importer les disponibilités en temps réel.</li>
<li>Association des créneaux détectés lors de lanalyse de fichiers aux calendriers connectés.</li>
<li>Centre de contrôle pour gérer les autorisations accordées à chaque profil.</li>
</CardList>
</Card>
<Card>
<CardTitle>En attendant</CardTitle>
<CardList>
<li>Continue dimporter un fichier pour préparer les plannings détaillés.</li>
<li>Centralise les informations clés dans la fiche profil pour garder le contexte accessible.</li>
<li>Utilise la vue calendrier mensuelle pour partager la vision densemble avec toute la famille.</li>
</CardList>
</Card>
<Actions>
<PrimaryButton type="button" onClick={() => navigate("/profiles")}>
Retour aux profils
</PrimaryButton>
<SecondaryButton type="button" onClick={() => navigate("/calendar/month")}>
Ouvrir le calendrier mensuel
</SecondaryButton>
<SecondaryButton type="button" onClick={() => navigate(-1)}>
Revenir à lécran précédent
</SecondaryButton>
<SecondaryButton
type="button"
onClick={() => {
if (typeof window !== "undefined") {
window.scrollTo({ top: 0, behavior: "smooth" });
}
}}
>
Remonter en haut de page
</SecondaryButton>
</Actions>
{profileId ? (
<Subtitle style={{ fontSize: "0.85rem" }}>
Identifiant technique : <code>{profileId}</code>
</Subtitle>
) : null}
</Container>
);
};

View File

@@ -0,0 +1,234 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useEffect, useState } from "react";
import styled from "styled-components";
import { useChildren } from "../state/ChildrenContext";
import { getIngestionStatus, getIngestionConfig, setOpenAIConfig, apiClient, startIngestion, repairIngestion } from "../services/api-client";
import { diagnoseUpload } from "../services/api-client";
const Container = styled.div `
max-width: 820px;
display: flex;
flex-direction: column;
gap: 24px;
`;
const Title = styled.h1 `
margin: 0;
font-size: 1.8rem;
`;
const Section = styled.section `
padding: 20px 24px;
border-radius: 18px;
background: rgba(29, 36, 66, 0.85);
border: 1px solid rgba(148, 156, 210, 0.18);
display: flex;
flex-direction: column;
gap: 12px;
`;
const SectionTitle = styled.h2 `
margin: 0;
font-size: 1.2rem;
`;
const SectionDescription = styled.p `
margin: 0;
color: var(--text-muted);
`;
const Placeholder = styled.div `
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px;
border-radius: 12px;
background: rgba(10, 14, 34, 0.6);
border: 1px dashed rgba(148, 156, 210, 0.28);
`;
const ArchivedList = styled.div `
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
`;
const ArchivedCard = styled.div `
padding: 16px;
border-radius: 14px;
background: rgba(12, 18, 42, 0.9);
border: 1px solid rgba(126, 136, 180, 0.24);
display: flex;
flex-direction: column;
gap: 8px;
`;
const ArchivedName = styled.span `
font-weight: 600;
`;
const ArchivedMeta = styled.span `
color: var(--text-muted);
font-size: 0.85rem;
`;
const RestoreButton = styled.button `
margin-top: 8px;
padding: 10px 12px;
border-radius: 12px;
border: none;
background: linear-gradient(135deg, #4cd964, #7df29d);
color: #04111d;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s ease;
&:hover {
transform: translateY(-1px);
}
`;
const DeleteForeverButton = styled.button `
margin-top: 8px;
padding: 10px 12px;
border-radius: 12px;
border: none;
background: rgba(255, 77, 109, 0.18);
color: #ffd7de;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s ease, background 0.2s ease;
&:hover {
transform: translateY(-1px);
background: rgba(255, 77, 109, 0.28);
}
`;
const StatusMessage = styled.div `
padding: 14px;
border-radius: 14px;
background: rgba(12, 18, 42, 0.7);
border: 1px solid rgba(126, 136, 180, 0.26);
color: var(--text-muted);
`;
export const SettingsScreen = () => {
const { archivedChildren, restoreChild, permanentlyDeleteChild } = useChildren();
const [actionMessage, setActionMessage] = useState(null);
const [ingestionStatus, setIngestionStatus] = useState("Inconnu");
const [openaiConfigured, setOpenaiConfigured] = useState(false);
const [model, setModel] = useState("gpt-4o");
const [apiKey, setApiKey] = useState("");
const [savingKey, setSavingKey] = useState(false);
const [history, setHistory] = useState([]);
const [minHours, setMinHours] = useState(() => Number(localStorage.getItem("fp:view:minHours") ?? 8));
const [maxHours, setMaxHours] = useState(() => Number(localStorage.getItem("fp:view:maxHours") ?? 12));
const [autoRefreshSec, setAutoRefreshSec] = useState(() => Number(localStorage.getItem("fp:view:autoRefreshSec") ?? 60));
const [timeZone, setTimeZone] = useState(() => localStorage.getItem("fp:view:timeZone") ?? "auto");
const [diagResult, setDiagResult] = useState(null);
const [diagBusy, setDiagBusy] = useState(false);
useEffect(() => {
const load = async () => {
try {
const status = await getIngestionStatus();
setIngestionStatus(status.ok ? "Disponible" : "Indisponible");
}
catch {
setIngestionStatus("Indisponible");
}
try {
const cfg = await getIngestionConfig();
setOpenaiConfigured(cfg.openaiConfigured);
if (cfg.model)
setModel(cfg.model);
}
catch {
// ignore
}
try {
const items = await apiClient.get("/schedules");
setHistory(items.sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, 20));
}
catch {
setHistory([]);
}
};
void load();
}, []);
const handleRestore = async (childId) => {
setActionMessage(null);
try {
await restoreChild(childId);
setActionMessage("Profil restaure avec succes. Il reaparait immediatement dans l agenda.");
}
catch (err) {
setActionMessage("La restauration a echoue. Merci de reessayer.");
}
};
const handlePermanentDelete = async (childId) => {
setActionMessage(null);
try {
const confirmDelete = window.confirm("Supprimer définitivement ce profil ? Cette action est irréversible.");
if (!confirmDelete)
return;
await permanentlyDeleteChild(childId);
setActionMessage("Profil supprime definitivement.");
}
catch (err) {
setActionMessage("La suppression definitive a echoue. Merci de reessayer.");
}
};
return (_jsxs(Container, { children: [_jsx(Title, { children: "Parametres et integrateurs" }), _jsxs(Section, { children: [_jsx(SectionTitle, { children: "Notifications" }), _jsx(SectionDescription, { children: "Configure les canaux d alerte (push navigateur, email, SMS)." }), _jsxs(Placeholder, { children: [_jsx("strong", { children: "TODO:" }), " Formulaire de configuration + selection des delais par defaut."] })] }), _jsxs(Section, { children: [_jsx(SectionTitle, { children: "Analyse des plannings (Ingestion)" }), _jsxs(SectionDescription, { children: ["Etat du service d'analyse: ", _jsx("strong", { children: ingestionStatus })] }), _jsxs(Placeholder, { children: [_jsxs("div", { style: { display: "flex", gap: 12, flexWrap: "wrap", alignItems: "center" }, children: [_jsx("button", { onClick: async () => {
setActionMessage(null);
try {
await startIngestion();
const status = await getIngestionStatus();
setIngestionStatus(status.ok ? "Disponible" : "Indisponible");
}
catch {
setIngestionStatus("Indisponible");
}
}, style: { padding: 10, borderRadius: 10 }, children: "D\u00E9marrer / relancer l'ingestion" }), _jsx("span", { style: { color: "var(--text-muted)", fontSize: "0.9rem" }, children: "Lance automatiquement si n\u00E9cessaire lors d'un import." }), _jsx("button", { onClick: async () => {
setActionMessage("Tentative de réparation…");
try {
const r = await repairIngestion();
if (r.ok) {
setActionMessage("Réparation effectuée. Le service devrait être disponible.");
}
else {
setActionMessage("Réparation en échec. Consulte les dépendances Python.");
}
const status = await getIngestionStatus();
setIngestionStatus(status.ok ? "Disponible" : "Indisponible");
}
catch {
setActionMessage("Réparation en échec. Vérifie l'installation Python/uvicorn.");
}
}, style: { padding: 10, borderRadius: 10 }, children: "R\u00E9parer automatiquement" })] }), _jsxs("div", { children: [_jsx("strong", { children: "OpenAI" }), " \u2014 Configure la cl\u00E9 API et le mod\u00E8le utilis\u00E9 pour analyser les images/PDF."] }), _jsxs("div", { style: { display: "flex", gap: 12, flexWrap: "wrap" }, children: [_jsxs("label", { style: { display: "flex", flexDirection: "column", gap: 6, minWidth: 260 }, children: ["Cl\u00E9 API", _jsx("input", { type: "password", placeholder: openaiConfigured ? "Déjà configurée (laisser vide)" : "sk-...", value: apiKey, onChange: (e) => setApiKey(e.target.value), style: { padding: 10, borderRadius: 10 } }), _jsx("small", { style: { color: "var(--text-muted)" }, children: "Laisse vide pour ne pas modifier la cl\u00E9." })] }), _jsxs("label", { style: { display: "flex", flexDirection: "column", gap: 6, minWidth: 260 }, children: ["Mod\u00E8le", _jsxs("select", { value: model, onChange: (e) => setModel(e.target.value), style: { padding: 10, borderRadius: 10 }, children: [_jsx("option", { value: "gpt-4o", children: "gpt-4o (Vision compl\u00E8te)" }), _jsx("option", { value: "gpt-4o-mini", children: "gpt-4o-mini (Rapide/\u00E9conomique)" }), _jsx("option", { value: "o4-mini", children: "o4-mini" }), _jsx("option", { value: "gpt-4.1-mini", children: "gpt-4.1-mini" }), _jsx("option", { value: "custom", children: "Autre (saisir manuellement)" })] })] }), model === "custom" ? (_jsxs("label", { style: { display: "flex", flexDirection: "column", gap: 6, minWidth: 260 }, children: ["Mod\u00E8le personnalis\u00E9", _jsx("input", { type: "text", placeholder: "ex: gpt-4o-realtime-preview", onChange: (e) => setModel(e.target.value), style: { padding: 10, borderRadius: 10 } })] })) : null] }), _jsx("div", { children: _jsx("button", { disabled: savingKey, onClick: async () => {
setSavingKey(true);
setActionMessage(null);
try {
const payloadKey = apiKey.trim().length > 0 ? apiKey.trim() : undefined;
await setOpenAIConfig(payloadKey ?? "", model);
setOpenaiConfigured(true);
setApiKey("");
setActionMessage("Configuration enregistrée. Les prochains imports utiliseront ce modèle.");
}
catch {
setActionMessage("Echec de l'enregistrement de la clé OpenAI.");
}
finally {
setSavingKey(false);
}
}, style: { padding: 10, borderRadius: 10 }, children: savingKey ? "Enregistrement..." : "Enregistrer" }) })] })] }), _jsxs(Section, { children: [_jsx(SectionTitle, { children: "Theme et affichage" }), _jsx(SectionDescription, { children: "Personnalise les couleurs, le mode plein ecran et les options multi ecran." }), _jsxs(Placeholder, { children: [_jsx("strong", { children: "TODO:" }), " Ajout d options UI (polices, densite, transitions)."] })] }), _jsxs(Section, { children: [_jsx(SectionTitle, { children: "Historique & restauration" }), _jsx(SectionDescription, { children: "Retrouve les profils supprimes recemment. Tu peux les reintroduire a tout moment." }), actionMessage ? _jsx(StatusMessage, { children: actionMessage }) : null, archivedChildren.length === 0 ? (_jsx(Placeholder, { children: "Aucun enfant archive pour le moment." })) : (_jsx(ArchivedList, { children: archivedChildren.map((child) => (_jsxs(ArchivedCard, { children: [_jsx(ArchivedName, { children: child.fullName }), child.deletedAt ? (_jsxs(ArchivedMeta, { children: ["Supprime le ", new Date(child.deletedAt).toLocaleDateString()] })) : null, _jsx(RestoreButton, { type: "button", onClick: () => void handleRestore(child.id), children: "Restaurer" }), _jsx(DeleteForeverButton, { type: "button", onClick: () => void handlePermanentDelete(child.id), children: "Supprimer definitivement" })] }, child.id))) }))] }), _jsxs(Section, { children: [_jsx(SectionTitle, { children: "Historique des imports de planning" }), _jsx(SectionDescription, { children: "Derniers fichiers import\u00E9s, par ordre chronologique." }), history.length === 0 ? (_jsx(Placeholder, { children: "Aucun import de planning pour le moment." })) : (_jsx("div", { style: { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", gap: 12 }, children: history.map((h) => (_jsxs("div", { style: { padding: 12, borderRadius: 12, border: "1px solid rgba(126,136,180,0.24)", background: "rgba(12,18,42,0.9)" }, children: [_jsx("div", { style: { fontWeight: 600 }, children: h.sourceFileName ?? "Fichier" }), _jsxs("div", { className: "muted", style: { color: "var(--text-muted)", fontSize: "0.85rem" }, children: [new Date(h.createdAt).toLocaleString(), " \u2014 Enfant: ", h.childId] }), h.sourceFileUrl ? (_jsx("a", { href: h.sourceFileUrl, target: "_blank", rel: "noreferrer", style: { color: "#9fb0ff" }, children: "Ouvrir le fichier" })) : null] }, h.id))) }))] }), _jsxs(Section, { children: [_jsx(SectionTitle, { children: "Affichage du planning" }), _jsx(SectionDescription, { children: "Configure la fen\u00EAtre visible par d\u00E9faut pour la vue g\u00E9n\u00E9rale et la fr\u00E9quence d\u2019auto\u2011rafra\u00EEchissement." }), _jsxs("div", { style: { display: "flex", gap: 12, flexWrap: "wrap" }, children: [_jsxs("label", { style: { display: "flex", flexDirection: "column", gap: 6, minWidth: 160 }, children: ["Heures min (fen\u00EAtre)", _jsx("input", { type: "number", min: 4, max: 24, value: minHours, onChange: (e) => setMinHours(Math.max(4, Math.min(24, Number(e.target.value)))), style: { padding: 10, borderRadius: 10 } })] }), _jsxs("label", { style: { display: "flex", flexDirection: "column", gap: 6, minWidth: 160 }, children: ["Heures max (fen\u00EAtre)", _jsx("input", { type: "number", min: minHours, max: 24, value: maxHours, onChange: (e) => setMaxHours(Math.max(minHours, Math.min(24, Number(e.target.value)))), style: { padding: 10, borderRadius: 10 } })] }), _jsxs("label", { style: { display: "flex", flexDirection: "column", gap: 6, minWidth: 200 }, children: ["Auto\u2011rafra\u00EEchissement", _jsxs("select", { value: autoRefreshSec, onChange: (e) => setAutoRefreshSec(Number(e.target.value)), style: { padding: 10, borderRadius: 10 }, children: [_jsx("option", { value: 30, children: "30 secondes" }), _jsx("option", { value: 60, children: "1 minute" }), _jsx("option", { value: 300, children: "5 minutes" })] })] }), _jsxs("label", { style: { display: "flex", flexDirection: "column", gap: 6, minWidth: 240 }, children: ["Fuseau horaire", _jsxs("select", { value: timeZone, onChange: (e) => setTimeZone(e.target.value), style: { padding: 10, borderRadius: 10 }, children: [_jsx("option", { value: "auto", children: "Automatique (syst\u00E8me)" }), _jsx("option", { value: "Europe/Paris", children: "Europe/Paris (Monaco/Paris)" }), _jsx("option", { value: "Europe/Monaco", children: "Europe/Monaco" }), _jsx("option", { value: "UTC", children: "UTC" })] })] })] }), _jsx("div", { children: _jsx("button", { onClick: () => {
localStorage.setItem("fp:view:minHours", String(minHours));
localStorage.setItem("fp:view:maxHours", String(maxHours));
localStorage.setItem("fp:view:autoRefreshSec", String(autoRefreshSec));
localStorage.setItem("fp:view:timeZone", timeZone);
setActionMessage("Préférences daffichage enregistrées.");
}, style: { padding: 10, borderRadius: 10 }, children: "Sauvegarder" }) })] }), _jsxs(Section, { children: [_jsx(SectionTitle, { children: "Diagnostic" }), _jsx(SectionDescription, { children: "V\u00E9rifie le chemin d'analyse d'un fichier (d\u00E9tection type, plan d'analyse, \u00E9tat du service)." }), _jsxs("div", { style: { display: "flex", gap: 12, alignItems: "center", flexWrap: "wrap" }, children: [_jsx("input", { type: "file", accept: "image/*,.pdf,.xls,.xlsx", onChange: async (e) => {
const file = e.target.files?.[0];
if (!file)
return;
setDiagBusy(true);
setDiagResult(null);
try {
const info = await diagnoseUpload(file);
setDiagResult(info);
}
catch {
setDiagResult(null);
alert("Diagnostic en échec. Vérifie le backend.");
}
finally {
setDiagBusy(false);
}
} }), diagBusy ? _jsx("span", { children: "Analyse du diagnostic\u2026" }) : null] }), diagResult ? (_jsxs(Placeholder, { children: [_jsxs("div", { children: [_jsx("strong", { children: "Fichier:" }), " ", diagResult.filename, " (", diagResult.mimetype, ")"] }), _jsxs("div", { children: [_jsx("strong", { children: "Type d\u00E9tect\u00E9:" }), " ", diagResult.detectedType] }), _jsxs("div", { children: [_jsx("strong", { children: "Plan d'analyse:" }), " ", diagResult.analysisPlan] }), _jsxs("div", { children: [_jsx("strong", { children: "Vision utilis\u00E9e:" }), " ", diagResult.wouldUseVision ? "Oui" : "Non"] }), _jsxs("div", { children: [_jsx("strong", { children: "Service ingestion:" }), " ", diagResult.ingestionHealthy ? "Disponible" : "Indisponible"] })] })) : null] })] }));
};

View File

@@ -0,0 +1,479 @@
import { useEffect, useState } from "react";
import styled from "styled-components";
import { useChildren } from "../state/ChildrenContext";
import { getIngestionStatus, getIngestionConfig, setOpenAIConfig, apiClient, startIngestion, repairIngestion } from "../services/api-client";
import { diagnoseUpload } from "../services/api-client";
const Container = styled.div`
max-width: 820px;
display: flex;
flex-direction: column;
gap: 24px;
`;
const Title = styled.h1`
margin: 0;
font-size: 1.8rem;
`;
const Section = styled.section`
padding: 20px 24px;
border-radius: 18px;
background: rgba(29, 36, 66, 0.85);
border: 1px solid rgba(148, 156, 210, 0.18);
display: flex;
flex-direction: column;
gap: 12px;
`;
const SectionTitle = styled.h2`
margin: 0;
font-size: 1.2rem;
`;
const SectionDescription = styled.p`
margin: 0;
color: var(--text-muted);
`;
const Placeholder = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px;
border-radius: 12px;
background: rgba(10, 14, 34, 0.6);
border: 1px dashed rgba(148, 156, 210, 0.28);
`;
const ArchivedList = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
`;
const ArchivedCard = styled.div`
padding: 16px;
border-radius: 14px;
background: rgba(12, 18, 42, 0.9);
border: 1px solid rgba(126, 136, 180, 0.24);
display: flex;
flex-direction: column;
gap: 8px;
`;
const ArchivedName = styled.span`
font-weight: 600;
`;
const ArchivedMeta = styled.span`
color: var(--text-muted);
font-size: 0.85rem;
`;
const RestoreButton = styled.button`
margin-top: 8px;
padding: 10px 12px;
border-radius: 12px;
border: none;
background: linear-gradient(135deg, #4cd964, #7df29d);
color: #04111d;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s ease;
&:hover {
transform: translateY(-1px);
}
`;
const DeleteForeverButton = styled.button`
margin-top: 8px;
padding: 10px 12px;
border-radius: 12px;
border: none;
background: rgba(255, 77, 109, 0.18);
color: #ffd7de;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s ease, background 0.2s ease;
&:hover {
transform: translateY(-1px);
background: rgba(255, 77, 109, 0.28);
}
`;
const StatusMessage = styled.div`
padding: 14px;
border-radius: 14px;
background: rgba(12, 18, 42, 0.7);
border: 1px solid rgba(126, 136, 180, 0.26);
color: var(--text-muted);
`;
export const SettingsScreen = () => {
const { archivedChildren, restoreChild, permanentlyDeleteChild } = useChildren();
const [actionMessage, setActionMessage] = useState<string | null>(null);
const [ingestionStatus, setIngestionStatus] = useState<string>("Inconnu");
const [openaiConfigured, setOpenaiConfigured] = useState<boolean>(false);
const [model, setModel] = useState<string>("gpt-4o");
const [apiKey, setApiKey] = useState<string>("");
const [savingKey, setSavingKey] = useState<boolean>(false);
const [history, setHistory] = useState<Array<{ id: string; childId: string; sourceFileUrl?: string; sourceFileName?: string; createdAt: string }>>([]);
const [minHours, setMinHours] = useState<number>(() => Number(localStorage.getItem("fp:view:minHours") ?? 8));
const [maxHours, setMaxHours] = useState<number>(() => Number(localStorage.getItem("fp:view:maxHours") ?? 12));
const [autoRefreshSec, setAutoRefreshSec] = useState<number>(() => Number(localStorage.getItem("fp:view:autoRefreshSec") ?? 60));
const [timeZone, setTimeZone] = useState<string>(() => localStorage.getItem("fp:view:timeZone") ?? "auto");
const [diagResult, setDiagResult] = useState<{ filename: string; mimetype: string; detectedType: string; analysisPlan: string; wouldUseVision: boolean; ingestionHealthy: boolean } | null>(null);
const [diagBusy, setDiagBusy] = useState<boolean>(false);
useEffect(() => {
const load = async () => {
try {
const status = await getIngestionStatus();
setIngestionStatus(status.ok ? "Disponible" : "Indisponible");
} catch {
setIngestionStatus("Indisponible");
}
try {
const cfg = await getIngestionConfig();
setOpenaiConfigured(cfg.openaiConfigured);
if (cfg.model) setModel(cfg.model);
} catch {
// ignore
}
try {
const items = await apiClient.get<Array<{ id: string; childId: string; sourceFileUrl?: string; sourceFileName?: string; createdAt: string }>>("/schedules");
setHistory(items.sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, 20));
} catch {
setHistory([]);
}
};
void load();
}, []);
const handleRestore = async (childId: string) => {
setActionMessage(null);
try {
await restoreChild(childId);
setActionMessage("Profil restaure avec succes. Il reaparait immediatement dans l agenda.");
} catch (err) {
setActionMessage("La restauration a echoue. Merci de reessayer.");
}
};
const handlePermanentDelete = async (childId: string) => {
setActionMessage(null);
try {
const confirmDelete = window.confirm(
"Supprimer définitivement ce profil ? Cette action est irréversible."
);
if (!confirmDelete) return;
await permanentlyDeleteChild(childId);
setActionMessage("Profil supprime definitivement.");
} catch (err) {
setActionMessage("La suppression definitive a echoue. Merci de reessayer.");
}
};
return (
<Container>
<Title>Parametres et integrateurs</Title>
<Section>
<SectionTitle>Notifications</SectionTitle>
<SectionDescription>
Configure les canaux d alerte (push navigateur, email, SMS).
</SectionDescription>
<Placeholder>
<strong>TODO:</strong> Formulaire de configuration + selection des delais par defaut.
</Placeholder>
</Section>
<Section>
<SectionTitle>Analyse des plannings (Ingestion)</SectionTitle>
<SectionDescription>
Etat du service d'analyse: <strong>{ingestionStatus}</strong>
</SectionDescription>
<Placeholder>
<div style={{ display: "flex", gap: 12, flexWrap: "wrap", alignItems: "center" }}>
<button
onClick={async () => {
setActionMessage(null);
try {
await startIngestion();
const status = await getIngestionStatus();
setIngestionStatus(status.ok ? "Disponible" : "Indisponible");
} catch {
setIngestionStatus("Indisponible");
}
}}
style={{ padding: 10, borderRadius: 10 }}
>
Démarrer / relancer l'ingestion
</button>
<span style={{ color: "var(--text-muted)", fontSize: "0.9rem" }}>
Lance automatiquement si nécessaire lors d'un import.
</span>
<button
onClick={async () => {
setActionMessage("Tentative de réparation…");
try {
const r = await repairIngestion();
if (r.ok) {
setActionMessage("Réparation effectuée. Le service devrait être disponible.");
} else {
setActionMessage("Réparation en échec. Consulte les dépendances Python.");
}
const status = await getIngestionStatus();
setIngestionStatus(status.ok ? "Disponible" : "Indisponible");
} catch {
setActionMessage("Réparation en échec. Vérifie l'installation Python/uvicorn.");
}
}}
style={{ padding: 10, borderRadius: 10 }}
>
Réparer automatiquement
</button>
</div>
<div>
<strong>OpenAI</strong> — Configure la clé API et le modèle utilisé pour analyser les images/PDF.
</div>
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
<label style={{ display: "flex", flexDirection: "column", gap: 6, minWidth: 260 }}>
Clé API
<input
type="password"
placeholder={openaiConfigured ? "Déjà configurée (laisser vide)" : "sk-..."}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
style={{ padding: 10, borderRadius: 10 }}
/>
<small style={{ color: "var(--text-muted)" }}>Laisse vide pour ne pas modifier la clé.</small>
</label>
<label style={{ display: "flex", flexDirection: "column", gap: 6, minWidth: 260 }}>
Modèle
<select
value={model}
onChange={(e) => setModel(e.target.value)}
style={{ padding: 10, borderRadius: 10 }}
>
<option value="gpt-4o">gpt-4o (Vision complète)</option>
<option value="gpt-4o-mini">gpt-4o-mini (Rapide/économique)</option>
<option value="o4-mini">o4-mini</option>
<option value="gpt-4.1-mini">gpt-4.1-mini</option>
<option value="custom">Autre (saisir manuellement)</option>
</select>
</label>
{model === "custom" ? (
<label style={{ display: "flex", flexDirection: "column", gap: 6, minWidth: 260 }}>
Modèle personnalisé
<input
type="text"
placeholder="ex: gpt-4o-realtime-preview"
onChange={(e) => setModel(e.target.value)}
style={{ padding: 10, borderRadius: 10 }}
/>
</label>
) : null}
</div>
<div>
<button
disabled={savingKey}
onClick={async () => {
setSavingKey(true);
setActionMessage(null);
try {
const payloadKey = apiKey.trim().length > 0 ? apiKey.trim() : undefined;
await setOpenAIConfig(payloadKey ?? "", model);
setOpenaiConfigured(true);
setApiKey("");
setActionMessage("Configuration enregistrée. Les prochains imports utiliseront ce modèle.");
} catch {
setActionMessage("Echec de l'enregistrement de la clé OpenAI.");
} finally {
setSavingKey(false);
}
}}
style={{ padding: 10, borderRadius: 10 }}
>
{savingKey ? "Enregistrement..." : "Enregistrer"}
</button>
</div>
</Placeholder>
</Section>
<Section>
<SectionTitle>Theme et affichage</SectionTitle>
<SectionDescription>
Personnalise les couleurs, le mode plein ecran et les options multi ecran.
</SectionDescription>
<Placeholder>
<strong>TODO:</strong> Ajout d options UI (polices, densite, transitions).
</Placeholder>
</Section>
<Section>
<SectionTitle>Historique & restauration</SectionTitle>
<SectionDescription>
Retrouve les profils supprimes recemment. Tu peux les reintroduire a tout moment.
</SectionDescription>
{actionMessage ? <StatusMessage>{actionMessage}</StatusMessage> : null}
{archivedChildren.length === 0 ? (
<Placeholder>Aucun enfant archive pour le moment.</Placeholder>
) : (
<ArchivedList>
{archivedChildren.map((child) => (
<ArchivedCard key={child.id}>
<ArchivedName>{child.fullName}</ArchivedName>
{child.deletedAt ? (
<ArchivedMeta>
Supprime le {new Date(child.deletedAt).toLocaleDateString()}
</ArchivedMeta>
) : null}
<RestoreButton type="button" onClick={() => void handleRestore(child.id)}>
Restaurer
</RestoreButton>
<DeleteForeverButton
type="button"
onClick={() => void handlePermanentDelete(child.id)}
>
Supprimer definitivement
</DeleteForeverButton>
</ArchivedCard>
))}
</ArchivedList>
)}
</Section>
<Section>
<SectionTitle>Historique des imports de planning</SectionTitle>
<SectionDescription>Derniers fichiers importés, par ordre chronologique.</SectionDescription>
{history.length === 0 ? (
<Placeholder>Aucun import de planning pour le moment.</Placeholder>
) : (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", gap: 12 }}>
{history.map((h) => (
<div key={h.id} style={{ padding: 12, borderRadius: 12, border: "1px solid rgba(126,136,180,0.24)", background: "rgba(12,18,42,0.9)" }}>
<div style={{ fontWeight: 600 }}>{h.sourceFileName ?? "Fichier"}</div>
<div className="muted" style={{ color: "var(--text-muted)", fontSize: "0.85rem" }}>
{new Date(h.createdAt).toLocaleString()} — Enfant: {h.childId}
</div>
{h.sourceFileUrl ? (
<a href={h.sourceFileUrl} target="_blank" rel="noreferrer" style={{ color: "#9fb0ff" }}>
Ouvrir le fichier
</a>
) : null}
</div>
))}
</div>
)}
</Section>
<Section>
<SectionTitle>Affichage du planning</SectionTitle>
<SectionDescription>
Configure la fenêtre visible par défaut pour la vue générale et la fréquence dautorafraîchissement.
</SectionDescription>
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
<label style={{ display: "flex", flexDirection: "column", gap: 6, minWidth: 160 }}>
Heures min (fenêtre)
<input
type="number"
min={4}
max={24}
value={minHours}
onChange={(e) => setMinHours(Math.max(4, Math.min(24, Number(e.target.value))))}
style={{ padding: 10, borderRadius: 10 }}
/>
</label>
<label style={{ display: "flex", flexDirection: "column", gap: 6, minWidth: 160 }}>
Heures max (fenêtre)
<input
type="number"
min={minHours}
max={24}
value={maxHours}
onChange={(e) => setMaxHours(Math.max(minHours, Math.min(24, Number(e.target.value))))}
style={{ padding: 10, borderRadius: 10 }}
/>
</label>
<label style={{ display: "flex", flexDirection: "column", gap: 6, minWidth: 200 }}>
Autorafraîchissement
<select
value={autoRefreshSec}
onChange={(e) => setAutoRefreshSec(Number(e.target.value))}
style={{ padding: 10, borderRadius: 10 }}
>
<option value={30}>30 secondes</option>
<option value={60}>1 minute</option>
<option value={300}>5 minutes</option>
</select>
</label>
<label style={{ display: "flex", flexDirection: "column", gap: 6, minWidth: 240 }}>
Fuseau horaire
<select
value={timeZone}
onChange={(e) => setTimeZone(e.target.value)}
style={{ padding: 10, borderRadius: 10 }}
>
<option value="auto">Automatique (système)</option>
<option value="Europe/Paris">Europe/Paris (Monaco/Paris)</option>
<option value="Europe/Monaco">Europe/Monaco</option>
<option value="UTC">UTC</option>
</select>
</label>
</div>
<div>
<button
onClick={() => {
localStorage.setItem("fp:view:minHours", String(minHours));
localStorage.setItem("fp:view:maxHours", String(maxHours));
localStorage.setItem("fp:view:autoRefreshSec", String(autoRefreshSec));
localStorage.setItem("fp:view:timeZone", timeZone);
setActionMessage("Préférences daffichage enregistrées.");
}}
style={{ padding: 10, borderRadius: 10 }}
>
Sauvegarder
</button>
</div>
</Section>
<Section>
<SectionTitle>Diagnostic</SectionTitle>
<SectionDescription>
Vérifie le chemin d'analyse d'un fichier (détection type, plan d'analyse, état du service).
</SectionDescription>
<div style={{ display: "flex", gap: 12, alignItems: "center", flexWrap: "wrap" }}>
<input
type="file"
accept="image/*,.pdf,.xls,.xlsx"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setDiagBusy(true);
setDiagResult(null);
try {
const info = await diagnoseUpload(file);
setDiagResult(info);
} catch {
setDiagResult(null);
alert("Diagnostic en échec. Vérifie le backend.");
} finally {
setDiagBusy(false);
}
}}
/>
{diagBusy ? <span>Analyse du diagnostic</span> : null}
</div>
{diagResult ? (
<Placeholder>
<div><strong>Fichier:</strong> {diagResult.filename} ({diagResult.mimetype})</div>
<div><strong>Type détecté:</strong> {diagResult.detectedType}</div>
<div><strong>Plan d'analyse:</strong> {diagResult.analysisPlan}</div>
<div><strong>Vision utilisée:</strong> {diagResult.wouldUseVision ? "Oui" : "Non"}</div>
<div><strong>Service ingestion:</strong> {diagResult.ingestionHealthy ? "Disponible" : "Indisponible"}</div>
</Placeholder>
) : null}
</Section>
</Container>
);
};

View File

@@ -0,0 +1,158 @@
/**
* Service de gestion des alertes dynamiques
*
* Les alertes sont générées automatiquement à partir des activités cochées
* et sont purgées automatiquement à minuit chaque jour.
*/
const STORAGE_KEY = "fp:alerts";
/**
* Charge les alertes depuis localStorage
*/
function loadAlerts() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
return {
alerts: Array.isArray(parsed.alerts) ? parsed.alerts : [],
lastPurgeDate: parsed.lastPurgeDate || new Date().toISOString().slice(0, 10)
};
}
}
catch (error) {
console.warn("Erreur lors du chargement des alertes", error);
}
return {
alerts: [],
lastPurgeDate: new Date().toISOString().slice(0, 10)
};
}
/**
* Sauvegarde les alertes dans localStorage
*/
function saveAlerts(storage) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(storage));
}
catch (error) {
console.warn("Erreur lors de la sauvegarde des alertes", error);
}
}
/**
* Génère un ID unique pour une alerte
*/
function generateAlertId() {
return `alert_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
}
/**
* Purge les alertes si on a changé de jour
*/
export function purgeAlertsIfNeeded() {
const storage = loadAlerts();
const today = new Date().toISOString().slice(0, 10);
// Si la dernière purge n'était pas aujourd'hui, on purge
if (storage.lastPurgeDate !== today) {
console.log(`Purge des alertes (dernière purge: ${storage.lastPurgeDate})`);
storage.alerts = [];
storage.lastPurgeDate = today;
saveAlerts(storage);
}
}
/**
* Récupère toutes les alertes (après purge automatique)
*/
export function getAllAlerts() {
purgeAlertsIfNeeded();
const storage = loadAlerts();
// Trier les alertes par date et heure
return storage.alerts.sort((a, b) => {
const dateCompare = a.date.localeCompare(b.date);
if (dateCompare !== 0)
return dateCompare;
return a.time.localeCompare(b.time);
});
}
/**
* Récupère les alertes pour un profil spécifique
*/
export function getAlertsForProfile(profileId) {
return getAllAlerts().filter(alert => alert.profileId === profileId);
}
/**
* Ajoute ou met à jour une alerte pour une activité
*/
export function addOrUpdateAlert(profileId, profileName, activity, color) {
purgeAlertsIfNeeded();
const storage = loadAlerts();
// Vérifier si l'alerte existe déjà pour cette activité
const existingIndex = storage.alerts.findIndex(alert => alert.activityId === activity.id && alert.profileId === profileId);
const activityDate = new Date(activity.startDateTime);
const activityDateStr = activityDate.toISOString().slice(0, 10);
const timeStr = activityDate.toLocaleTimeString("fr-FR", {
hour: "2-digit",
minute: "2-digit"
});
// Format du titre avec le nom du profil
const title = `${activity.title} (${profileName})`;
// Calcul de la date relative (aujourd'hui, demain, etc.)
const today = new Date().toISOString().slice(0, 10);
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const tomorrowStr = tomorrow.toISOString().slice(0, 10);
let timeLabel = timeStr;
if (activityDateStr === today) {
timeLabel = `Aujourd'hui ${timeStr}`;
}
else if (activityDateStr === tomorrowStr) {
timeLabel = `Demain ${timeStr}`;
}
else {
const dayName = activityDate.toLocaleDateString("fr-FR", { weekday: "long" });
timeLabel = `${dayName.charAt(0).toUpperCase() + dayName.slice(1)} ${timeStr}`;
}
const alert = {
id: existingIndex >= 0 ? storage.alerts[existingIndex].id : generateAlertId(),
profileId,
profileName,
title,
time: timeLabel,
date: activityDateStr,
color,
note: activity.notes,
activityId: activity.id,
createdAt: new Date().toISOString()
};
if (existingIndex >= 0) {
storage.alerts[existingIndex] = alert;
}
else {
storage.alerts.push(alert);
}
saveAlerts(storage);
}
/**
* Supprime une alerte pour une activité
*/
export function removeAlert(activityId, profileId) {
purgeAlertsIfNeeded();
const storage = loadAlerts();
storage.alerts = storage.alerts.filter(alert => !(alert.activityId === activityId && alert.profileId === profileId));
saveAlerts(storage);
}
/**
* Supprime toutes les alertes
*/
export function clearAllAlerts() {
const storage = {
alerts: [],
lastPurgeDate: new Date().toISOString().slice(0, 10)
};
saveAlerts(storage);
}
/**
* Vérifie si une activité a une alerte
*/
export function hasAlert(activityId, profileId) {
const storage = loadAlerts();
return storage.alerts.some(alert => alert.activityId === activityId && alert.profileId === profileId);
}

View File

@@ -0,0 +1,211 @@
/**
* Service de gestion des alertes dynamiques
*
* Les alertes sont générées automatiquement à partir des activités cochées
* et sont purgées automatiquement à minuit chaque jour.
*/
export interface Alert {
id: string;
profileId: string;
profileName: string;
title: string;
time: string;
date: string; // ISO date string
color: string;
note?: string;
activityId: string;
createdAt: string; // ISO timestamp
}
interface AlertsStorage {
alerts: Alert[];
lastPurgeDate: string; // ISO date string
}
const STORAGE_KEY = "fp:alerts";
/**
* Charge les alertes depuis localStorage
*/
function loadAlerts(): AlertsStorage {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
return {
alerts: Array.isArray(parsed.alerts) ? parsed.alerts : [],
lastPurgeDate: parsed.lastPurgeDate || new Date().toISOString().slice(0, 10)
};
}
} catch (error) {
console.warn("Erreur lors du chargement des alertes", error);
}
return {
alerts: [],
lastPurgeDate: new Date().toISOString().slice(0, 10)
};
}
/**
* Sauvegarde les alertes dans localStorage
*/
function saveAlerts(storage: AlertsStorage): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(storage));
} catch (error) {
console.warn("Erreur lors de la sauvegarde des alertes", error);
}
}
/**
* Génère un ID unique pour une alerte
*/
function generateAlertId(): string {
return `alert_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
}
/**
* Purge les alertes si on a changé de jour
*/
export function purgeAlertsIfNeeded(): void {
const storage = loadAlerts();
const today = new Date().toISOString().slice(0, 10);
// Si la dernière purge n'était pas aujourd'hui, on purge
if (storage.lastPurgeDate !== today) {
console.log(`Purge des alertes (dernière purge: ${storage.lastPurgeDate})`);
storage.alerts = [];
storage.lastPurgeDate = today;
saveAlerts(storage);
}
}
/**
* Récupère toutes les alertes (après purge automatique)
*/
export function getAllAlerts(): Alert[] {
purgeAlertsIfNeeded();
const storage = loadAlerts();
// Trier les alertes par date et heure
return storage.alerts.sort((a, b) => {
const dateCompare = a.date.localeCompare(b.date);
if (dateCompare !== 0) return dateCompare;
return a.time.localeCompare(b.time);
});
}
/**
* Récupère les alertes pour un profil spécifique
*/
export function getAlertsForProfile(profileId: string): Alert[] {
return getAllAlerts().filter(alert => alert.profileId === profileId);
}
/**
* Ajoute ou met à jour une alerte pour une activité
*/
export function addOrUpdateAlert(
profileId: string,
profileName: string,
activity: {
id: string;
title: string;
startDateTime: string;
endDateTime: string;
notes?: string;
},
color: string
): void {
purgeAlertsIfNeeded();
const storage = loadAlerts();
// Vérifier si l'alerte existe déjà pour cette activité
const existingIndex = storage.alerts.findIndex(
alert => alert.activityId === activity.id && alert.profileId === profileId
);
const activityDate = new Date(activity.startDateTime);
const activityDateStr = activityDate.toISOString().slice(0, 10);
const timeStr = activityDate.toLocaleTimeString("fr-FR", {
hour: "2-digit",
minute: "2-digit"
});
// Format du titre avec le nom du profil
const title = `${activity.title} (${profileName})`;
// Calcul de la date relative (aujourd'hui, demain, etc.)
const today = new Date().toISOString().slice(0, 10);
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const tomorrowStr = tomorrow.toISOString().slice(0, 10);
let timeLabel = timeStr;
if (activityDateStr === today) {
timeLabel = `Aujourd'hui ${timeStr}`;
} else if (activityDateStr === tomorrowStr) {
timeLabel = `Demain ${timeStr}`;
} else {
const dayName = activityDate.toLocaleDateString("fr-FR", { weekday: "long" });
timeLabel = `${dayName.charAt(0).toUpperCase() + dayName.slice(1)} ${timeStr}`;
}
const alert: Alert = {
id: existingIndex >= 0 ? storage.alerts[existingIndex].id : generateAlertId(),
profileId,
profileName,
title,
time: timeLabel,
date: activityDateStr,
color,
note: activity.notes,
activityId: activity.id,
createdAt: new Date().toISOString()
};
if (existingIndex >= 0) {
storage.alerts[existingIndex] = alert;
} else {
storage.alerts.push(alert);
}
saveAlerts(storage);
}
/**
* Supprime une alerte pour une activité
*/
export function removeAlert(activityId: string, profileId: string): void {
purgeAlertsIfNeeded();
const storage = loadAlerts();
storage.alerts = storage.alerts.filter(
alert => !(alert.activityId === activityId && alert.profileId === profileId)
);
saveAlerts(storage);
}
/**
* Supprime toutes les alertes
*/
export function clearAllAlerts(): void {
const storage: AlertsStorage = {
alerts: [],
lastPurgeDate: new Date().toISOString().slice(0, 10)
};
saveAlerts(storage);
}
/**
* Vérifie si une activité a une alerte
*/
export function hasAlert(activityId: string, profileId: string): boolean {
const storage = loadAlerts();
return storage.alerts.some(
alert => alert.activityId === activityId && alert.profileId === profileId
);
}

View File

@@ -0,0 +1,146 @@
export const API_BASE_URL = import.meta.env.VITE_API_URL ?? "http://localhost:5000/api";
async function request(path, method, body) {
const response = await fetch(`${API_BASE_URL}${path}`, {
method,
headers: {
"Content-Type": "application/json",
"Cache-Control": "no-cache" // Force le backend à retourner 200 au lieu de 304
},
body: body ? JSON.stringify(body) : undefined
});
if (!response.ok) {
throw new Error(`API error ${response.status}`);
}
// Gérer 304 Not Modified (pas de body, utiliser le cache navigateur)
if (response.status === 304) {
throw new Error("Data not modified (304) - this should not happen with Cache-Control: no-cache");
}
if (response.status === 204) {
return undefined;
}
const contentLength = response.headers.get("Content-Length");
if (contentLength === "0" || response.status === 202) {
return undefined;
}
const contentType = response.headers.get("Content-Type") ?? "";
if (!contentType.includes("application/json")) {
return undefined;
}
return (await response.json());
}
export const apiClient = {
get: (path) => request(path, "GET"),
post: (path, payload) => request(path, "POST", payload),
patch: (path, payload) => request(path, "PATCH", payload),
delete: (path) => request(path, "DELETE")
};
export const uploadAvatar = async (file) => {
const formData = new FormData();
formData.append("avatar", file);
const response = await fetch(`${API_BASE_URL}/uploads/avatar`, {
method: "POST",
body: formData
});
if (!response.ok) {
throw new Error(`Upload error ${response.status}`);
}
return (await response.json());
};
export const uploadPlanning = async (childId, file) => {
const form = new FormData();
form.append("childId", childId);
form.append("planning", file);
const response = await fetch(`${API_BASE_URL}/uploads/planning`, {
method: "POST",
body: form
});
if (!response.ok) {
throw new Error(`Upload planning error ${response.status}`);
}
return (await response.json());
};
export const listAvatars = async () => {
const response = await fetch(`${API_BASE_URL}/uploads/avatars`);
if (!response.ok) {
throw new Error(`Gallery error ${response.status}`);
}
return (await response.json());
};
// Ingestion helper endpoints (proxied via backend)
export const getIngestionStatus = async () => {
const response = await fetch(`${API_BASE_URL}/ingestion/status`);
return (await response.json());
};
export const getIngestionConfig = async () => {
const response = await fetch(`${API_BASE_URL}/ingestion/config`);
return (await response.json());
};
export const setOpenAIConfig = async (apiKey, model) => {
const response = await fetch(`${API_BASE_URL}/ingestion/config/openai`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ apiKey, model })
});
if (!response.ok) {
throw new Error("Config update failed");
}
return (await response.json());
};
export const startIngestion = async () => {
const response = await fetch(`${API_BASE_URL}/ingestion/start`, { method: "POST" });
return (await response.json());
};
export const diagnoseUpload = async (file) => {
const form = new FormData();
form.append("planning", file);
const resp = await fetch(`${API_BASE_URL}/uploads/diagnose`, { method: "POST", body: form });
if (!resp.ok)
throw new Error("Diagnostic failed");
return (await resp.json());
};
export const repairIngestion = async () => {
const resp = await fetch(`${API_BASE_URL}/ingestion/repair`, { method: "POST" });
return (await resp.json());
};
export const listParents = async () => apiClient.get("/parents");
export const createParent = async (payload) => apiClient.post("/parents", payload);
export const updateParent = async (parentId, payload) => apiClient.patch(`/parents/${parentId}`, payload);
export const deleteParent = async (parentId) => apiClient.delete(`/parents/${parentId}`);
export const listGrandParents = async () => apiClient.get("/grandparents");
export const createGrandParent = async (payload) => apiClient.post("/grandparents", payload);
export const updateGrandParent = async (grandParentId, payload) => apiClient.patch(`/grandparents/${grandParentId}`, payload);
export const deleteGrandParent = async (grandParentId) => apiClient.delete(`/grandparents/${grandParentId}`);
export const startCalendarOAuth = async (provider, payload) => apiClient.post(`/calendar/${provider}/oauth/start`, payload);
export const completeCalendarOAuth = async (payload) => apiClient.post("/calendar/oauth/complete", payload);
export const connectCalendarWithCredentials = async (provider, payload) => apiClient.post(`/calendar/${provider}/credentials`, payload);
export const listCalendarConnections = async (profileId) => apiClient.get(`/calendar/${profileId}/connections`);
export const refreshCalendarConnection = async (profileId, connectionId) => apiClient.post(`/calendar/${profileId}/connections/${connectionId}/refresh`, {});
export const removeCalendarConnection = async (profileId, connectionId) => apiClient.delete(`/calendar/${profileId}/connections/${connectionId}`);
export const getHolidays = async (region, year) => {
const params = new URLSearchParams();
if (region)
params.append("region", region);
if (year)
params.append("year", year.toString());
return apiClient.get(`/holidays?${params.toString()}`);
};
export const getPublicHolidays = async (year) => {
const params = new URLSearchParams();
if (year)
params.append("year", year.toString());
return apiClient.get(`/holidays/public?${params.toString()}`);
};
export const getSchoolHolidays = async (region, year) => {
const params = new URLSearchParams();
if (region)
params.append("region", region);
if (year)
params.append("year", year.toString());
return apiClient.get(`/holidays/school?${params.toString()}`);
};
export const getPersonalLeaves = async (profileId) => {
const params = new URLSearchParams();
if (profileId)
params.append("profileId", profileId);
return apiClient.get(`/personal-leaves?${params.toString()}`);
};

View File

@@ -0,0 +1,244 @@
import type { CalendarConnectionStatus, CalendarProvider, ConnectedCalendar } from "../types/calendar";
import type { ChildProfile } from "@family-planner/types";
import type {
ScheduleUploadResponse,
IngestionStatusResponse,
IngestionConfigResponse,
DiagnoseUploadResponse,
RepairIngestionResponse,
ApiSuccessResponse
} from "../types/api";
export const API_BASE_URL =
import.meta.env.VITE_API_URL ?? "http://localhost:5000/api";
type HttpVerb = "GET" | "POST" | "PATCH" | "DELETE";
async function request<TResponse>(
path: string,
method: HttpVerb,
body?: unknown
): Promise<TResponse> {
const response = await fetch(`${API_BASE_URL}${path}`, {
method,
headers: {
"Content-Type": "application/json",
"Cache-Control": "no-cache" // Force le backend à retourner 200 au lieu de 304
},
body: body ? JSON.stringify(body) : undefined
});
if (!response.ok) {
throw new Error(`API error ${response.status}`);
}
// Gérer 304 Not Modified (pas de body, utiliser le cache navigateur)
if (response.status === 304) {
throw new Error("Data not modified (304) - this should not happen with Cache-Control: no-cache");
}
if (response.status === 204) {
return undefined as TResponse;
}
const contentLength = response.headers.get("Content-Length");
if (contentLength === "0" || response.status === 202) {
return undefined as TResponse;
}
const contentType = response.headers.get("Content-Type") ?? "";
if (!contentType.includes("application/json")) {
return undefined as TResponse;
}
return (await response.json()) as TResponse;
}
export const apiClient = {
get: <T>(path: string) => request<T>(path, "GET"),
post: <T>(path: string, payload: unknown) => request<T>(path, "POST", payload),
patch: <T>(path: string, payload: unknown) => request<T>(path, "PATCH", payload),
delete: <T>(path: string) => request<T>(path, "DELETE")
};
export const uploadAvatar = async (
file: File
): Promise<{ url: string; filename: string }> => {
const formData = new FormData();
formData.append("avatar", file);
const response = await fetch(`${API_BASE_URL}/uploads/avatar`, {
method: "POST",
body: formData
});
if (!response.ok) {
throw new Error(`Upload error ${response.status}`);
}
return (await response.json()) as { url: string; filename: string };
};
export const uploadPlanning = async (
childId: string,
file: File
): Promise<ScheduleUploadResponse> => {
const form = new FormData();
form.append("childId", childId);
form.append("planning", file);
const response = await fetch(`${API_BASE_URL}/uploads/planning`, {
method: "POST",
body: form
});
if (!response.ok) {
throw new Error(`Upload planning error ${response.status}`);
}
return (await response.json()) as ScheduleUploadResponse;
};
export const listAvatars = async (): Promise<Array<{ filename: string; url: string }>> => {
const response = await fetch(`${API_BASE_URL}/uploads/avatars`);
if (!response.ok) {
throw new Error(`Gallery error ${response.status}`);
}
return (await response.json()) as Array<{ filename: string; url: string }>;
};
// Ingestion helper endpoints (proxied via backend)
export const getIngestionStatus = async (): Promise<IngestionStatusResponse> => {
const response = await fetch(`${API_BASE_URL}/ingestion/status`);
return (await response.json()) as IngestionStatusResponse;
};
export const getIngestionConfig = async (): Promise<IngestionConfigResponse> => {
const response = await fetch(`${API_BASE_URL}/ingestion/config`);
return (await response.json()) as IngestionConfigResponse;
};
export const setOpenAIConfig = async (apiKey: string, model?: string): Promise<ApiSuccessResponse> => {
const response = await fetch(`${API_BASE_URL}/ingestion/config/openai`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ apiKey, model })
});
if (!response.ok) {
throw new Error("Config update failed");
}
return (await response.json()) as ApiSuccessResponse;
};
export const startIngestion = async (): Promise<ApiSuccessResponse> => {
const response = await fetch(`${API_BASE_URL}/ingestion/start`, { method: "POST" });
return (await response.json()) as ApiSuccessResponse;
};
export const diagnoseUpload = async (
file: File
): Promise<DiagnoseUploadResponse> => {
const form = new FormData();
form.append("planning", file);
const resp = await fetch(`${API_BASE_URL}/uploads/diagnose`, { method: "POST", body: form });
if (!resp.ok) throw new Error("Diagnostic failed");
return (await resp.json()) as DiagnoseUploadResponse;
};
export const repairIngestion = async (): Promise<RepairIngestionResponse> => {
const resp = await fetch(`${API_BASE_URL}/ingestion/repair`, { method: "POST" });
return (await resp.json()) as RepairIngestionResponse;
};
// Parents API
export type ParentPayload = { fullName: string; colorHex: string; email?: string; notes?: string; avatar?: ChildProfile["avatar"] };
export const listParents = async (): Promise<Array<{ id: string; fullName: string; colorHex: string; email?: string; notes?: string; avatar?: ChildProfile["avatar"]; }>> =>
apiClient.get("/parents");
export const createParent = async (payload: ParentPayload) => apiClient.post("/parents", payload);
export const updateParent = async (parentId: string, payload: Partial<ParentPayload> & { avatar?: ChildProfile["avatar"] | null }) =>
apiClient.patch(`/parents/${parentId}`, payload);
export const deleteParent = async (parentId: string) => apiClient.delete(`/parents/${parentId}`);
export type GrandParentPayload = ParentPayload;
export const listGrandParents = async (): Promise<Array<{ id: string; fullName: string; colorHex: string; email?: string; notes?: string; avatar?: ChildProfile["avatar"]; }>> =>
apiClient.get("/grandparents");
export const createGrandParent = async (payload: GrandParentPayload) => apiClient.post("/grandparents", payload);
export const updateGrandParent = async (grandParentId: string, payload: Partial<GrandParentPayload> & { avatar?: ChildProfile["avatar"] | null }) =>
apiClient.patch(`/grandparents/${grandParentId}`, payload);
export const deleteGrandParent = async (grandParentId: string) => apiClient.delete(`/grandparents/${grandParentId}`);
// Calendar integrations
type CalendarOAuthStartResponse = {
authUrl?: string;
url?: string;
authorizeUrl?: string;
state?: string;
};
type CalendarOAuthCompleteResponse = {
success: boolean;
email?: string;
label?: string;
connectionId?: string;
profileId?: string;
};
export const startCalendarOAuth = async (
provider: CalendarProvider,
payload: { profileId: string; state: string }
) => apiClient.post<CalendarOAuthStartResponse>(`/calendar/${provider}/oauth/start`, payload);
export const completeCalendarOAuth = async (payload: {
provider: CalendarProvider;
state: string;
code?: string;
error?: string;
profileId?: string;
}) => apiClient.post<CalendarOAuthCompleteResponse>("/calendar/oauth/complete", payload);
export const connectCalendarWithCredentials = async (
provider: CalendarProvider,
payload: { profileId: string; email: string; password: string; label?: string; shareWithFamily?: boolean }
) => apiClient.post<{ connection: ConnectedCalendar }>(`/calendar/${provider}/credentials`, payload);
export const listCalendarConnections = async (profileId: string) =>
apiClient.get<ConnectedCalendar[]>(`/calendar/${profileId}/connections`);
export const refreshCalendarConnection = async (profileId: string, connectionId: string) =>
apiClient.post<{ status: CalendarConnectionStatus; lastSyncedAt?: string }>(
`/calendar/${profileId}/connections/${connectionId}/refresh`,
{}
);
export const removeCalendarConnection = async (profileId: string, connectionId: string) =>
apiClient.delete(`/calendar/${profileId}/connections/${connectionId}`);
// Holidays and Personal Leaves API
import type { Holiday, PersonalLeave, SchoolRegion } from "@family-planner/types";
export const getHolidays = async (region?: SchoolRegion, year?: number): Promise<{ holidays: Holiday[] }> => {
const params = new URLSearchParams();
if (region) params.append("region", region);
if (year) params.append("year", year.toString());
return apiClient.get(`/holidays?${params.toString()}`);
};
export const getPublicHolidays = async (year?: number): Promise<{ holidays: Holiday[] }> => {
const params = new URLSearchParams();
if (year) params.append("year", year.toString());
return apiClient.get(`/holidays/public?${params.toString()}`);
};
export const getSchoolHolidays = async (region?: SchoolRegion, year?: number): Promise<{ holidays: Holiday[] }> => {
const params = new URLSearchParams();
if (region) params.append("region", region);
if (year) params.append("year", year.toString());
return apiClient.get(`/holidays/school?${params.toString()}`);
};
export const getPersonalLeaves = async (profileId?: string): Promise<{ leaves: PersonalLeave[] }> => {
const params = new URLSearchParams();
if (profileId) params.append("profileId", profileId);
return apiClient.get(`/personal-leaves?${params.toString()}`);
};

View File

@@ -0,0 +1,112 @@
import { jsx as _jsx } from "react/jsx-runtime";
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { apiClient } from "../services/api-client";
const ChildrenContext = createContext(undefined);
const fetchChildrenData = async () => {
const [active, archived] = await Promise.all([
apiClient.get("/children"),
apiClient.get("/children/archived")
]);
return { active, archived };
};
export const ChildrenProvider = ({ children }) => {
const [activeChildren, setActiveChildren] = useState([]);
const [archivedChildren, setArchivedChildren] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const load = useCallback(async () => {
try {
setLoading(true);
const data = await fetchChildrenData();
setActiveChildren(data.active);
setArchivedChildren(data.archived);
setError(null);
console.log(`${data.active.length} enfants chargés avec succès`);
}
catch (err) {
console.error("❌ Erreur chargement enfants:", err);
const errorMessage = err instanceof Error ? err.message : "Erreur inconnue";
setError(`Impossible de charger les profils enfants: ${errorMessage}`);
// Retry automatique après 3 secondes si c'est une erreur réseau
if (errorMessage.includes("fetch") || errorMessage.includes("network") || errorMessage.includes("API error")) {
console.log("🔄 Nouvelle tentative dans 3 secondes...");
setTimeout(() => {
console.log("🔄 Retry du chargement des enfants...");
void load();
}, 3000);
}
}
finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const createChild = useCallback(async (payload) => {
const child = await apiClient.post("/children", payload);
setActiveChildren((previous) => [child, ...previous]);
return child;
}, []);
const deleteChild = useCallback(async (childId) => {
await apiClient.delete(`/children/${childId}`);
setActiveChildren((previous) => previous.filter((child) => child.id !== childId));
try {
const archived = await apiClient.get("/children/archived");
setArchivedChildren(archived);
}
catch {
// swallow - archived list will refresh on next load
}
}, []);
const updateChild = useCallback(async (childId, payload) => {
const updated = await apiClient.patch(`/children/${childId}`, payload);
setActiveChildren((previous) => previous.map((child) => (child.id === childId ? updated : child)));
return updated;
}, []);
const permanentlyDeleteChild = useCallback(async (childId) => {
await apiClient.delete(`/children/${childId}/permanent`);
setArchivedChildren((previous) => previous.filter((child) => child.id !== childId));
}, []);
const restoreChild = useCallback(async (childId) => {
const restored = await apiClient.post(`/children/${childId}/restore`, {});
setArchivedChildren((previous) => previous.filter((child) => child.id !== childId));
if (restored) {
setActiveChildren((previous) => [restored, ...previous.filter((child) => child.id !== childId)]);
}
else {
await load();
}
}, [load]);
const value = useMemo(() => ({
children: activeChildren,
archivedChildren,
loading,
error,
refresh: load,
createChild,
updateChild,
deleteChild,
restoreChild,
permanentlyDeleteChild
}), [
activeChildren,
archivedChildren,
loading,
error,
load,
createChild,
updateChild,
deleteChild,
restoreChild,
permanentlyDeleteChild
]);
return _jsx(ChildrenContext.Provider, { value: value, children: children });
};
export const useChildren = () => {
const context = useContext(ChildrenContext);
if (!context) {
throw new Error("useChildren must be used within a ChildrenProvider");
}
return context;
};

View File

@@ -0,0 +1,171 @@
import {
ReactNode,
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState
} from "react";
import { ChildProfile, SchoolRegion } from "@family-planner/types";
import { apiClient } from "../services/api-client";
export type CreateChildPayload = {
fullName: string;
colorHex: string;
email?: string;
notes?: string;
avatar?: ChildProfile["avatar"];
};
export type UpdateChildPayload = {
fullName?: string;
colorHex?: string;
email?: string;
notes?: string;
avatar?: ChildProfile["avatar"];
schoolRegion?: SchoolRegion;
};
type ChildrenContextValue = {
children: ChildProfile[];
archivedChildren: ChildProfile[];
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
createChild: (payload: CreateChildPayload) => Promise<ChildProfile>;
updateChild: (childId: string, payload: UpdateChildPayload) => Promise<ChildProfile>;
deleteChild: (childId: string) => Promise<void>;
restoreChild: (childId: string) => Promise<void>;
permanentlyDeleteChild: (childId: string) => Promise<void>;
};
const ChildrenContext = createContext<ChildrenContextValue | undefined>(undefined);
const fetchChildrenData = async (): Promise<{
active: ChildProfile[];
archived: ChildProfile[];
}> => {
const [active, archived] = await Promise.all([
apiClient.get<ChildProfile[]>("/children"),
apiClient.get<ChildProfile[]>("/children/archived")
]);
return { active, archived };
};
export const ChildrenProvider = ({ children }: { children: ReactNode }) => {
const [activeChildren, setActiveChildren] = useState<ChildProfile[]>([]);
const [archivedChildren, setArchivedChildren] = useState<ChildProfile[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
try {
setLoading(true);
const data = await fetchChildrenData();
setActiveChildren(data.active);
setArchivedChildren(data.archived);
setError(null);
console.log(`${data.active.length} enfants chargés avec succès`);
} catch (err) {
console.error("❌ Erreur chargement enfants:", err);
const errorMessage = err instanceof Error ? err.message : "Erreur inconnue";
setError(`Impossible de charger les profils enfants: ${errorMessage}`);
// Retry automatique après 3 secondes si c'est une erreur réseau
if (errorMessage.includes("fetch") || errorMessage.includes("network") || errorMessage.includes("API error")) {
console.log("🔄 Nouvelle tentative dans 3 secondes...");
setTimeout(() => {
console.log("🔄 Retry du chargement des enfants...");
void load();
}, 3000);
}
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const createChild = useCallback(async (payload: CreateChildPayload) => {
const child = await apiClient.post<ChildProfile>("/children", payload);
setActiveChildren((previous) => [child, ...previous]);
return child;
}, []);
const deleteChild = useCallback(async (childId: string) => {
await apiClient.delete(`/children/${childId}`);
setActiveChildren((previous) => previous.filter((child) => child.id !== childId));
try {
const archived = await apiClient.get<ChildProfile[]>("/children/archived");
setArchivedChildren(archived);
} catch {
// swallow - archived list will refresh on next load
}
}, []);
const updateChild = useCallback(
async (childId: string, payload: UpdateChildPayload) => {
const updated = await apiClient.patch<ChildProfile>(`/children/${childId}`, payload);
setActiveChildren((previous) =>
previous.map((child) => (child.id === childId ? updated : child))
);
return updated;
},
[]
);
const permanentlyDeleteChild = useCallback(async (childId: string) => {
await apiClient.delete(`/children/${childId}/permanent`);
setArchivedChildren((previous) => previous.filter((child) => child.id !== childId));
}, []);
const restoreChild = useCallback(async (childId: string) => {
const restored = await apiClient.post<ChildProfile>(`/children/${childId}/restore`, {});
setArchivedChildren((previous) => previous.filter((child) => child.id !== childId));
if (restored) {
setActiveChildren((previous) => [restored, ...previous.filter((child) => child.id !== childId)]);
} else {
await load();
}
}, [load]);
const value = useMemo<ChildrenContextValue>(
() => ({
children: activeChildren,
archivedChildren,
loading,
error,
refresh: load,
createChild,
updateChild,
deleteChild,
restoreChild,
permanentlyDeleteChild
}),
[
activeChildren,
archivedChildren,
loading,
error,
load,
createChild,
updateChild,
deleteChild,
restoreChild,
permanentlyDeleteChild
]
);
return <ChildrenContext.Provider value={value}>{children}</ChildrenContext.Provider>;
};
export const useChildren = () => {
const context = useContext(ChildrenContext);
if (!context) {
throw new Error("useChildren must be used within a ChildrenProvider");
}
return context;
};

View File

@@ -0,0 +1,13 @@
const STORAGE_KEY = "fp:parents";
export function loadParents() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : [];
}
catch {
return [];
}
}
export function saveParents(parents) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(parents));
}

View File

@@ -0,0 +1,24 @@
export type ParentProfile = {
id: string;
fullName: string;
colorHex: string;
email?: string;
notes?: string;
createdAt: string;
};
const STORAGE_KEY = "fp:parents";
export function loadParents(): ParentProfile[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as ParentProfile[]) : [];
} catch {
return [];
}
}
export function saveParents(parents: ParentProfile[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(parents));
}

View File

@@ -0,0 +1,116 @@
import { useCallback, useEffect, useMemo, useState } from "react";
const STORAGE_KEY = "fp:calendarConnections";
const randomId = () => {
try {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
}
catch {
// ignore
}
return `cal_${Math.random().toString(36).slice(2, 11)}`;
};
const readStorage = () => {
if (typeof window === "undefined")
return {};
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
if (!stored)
return {};
const parsed = JSON.parse(stored);
return parsed ?? {};
}
catch {
return {};
}
};
const writeStorage = (value) => {
if (typeof window === "undefined")
return;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(value));
}
catch {
// ignore persistence errors
}
};
export const useCalendarIntegrations = () => {
const [connections, setConnections] = useState(() => readStorage());
useEffect(() => {
writeStorage(connections);
}, [connections]);
useEffect(() => {
if (typeof window === "undefined")
return;
const handler = (event) => {
if (event.key !== STORAGE_KEY || event.newValue == null)
return;
try {
const parsed = JSON.parse(event.newValue);
setConnections(parsed ?? {});
}
catch {
// ignore malformed payload
}
};
window.addEventListener("storage", handler);
return () => window.removeEventListener("storage", handler);
}, []);
const getConnections = useCallback((profileId) => connections[profileId] ?? [], [connections]);
const addConnection = useCallback((profileId, provider, payload) => {
const connectionId = randomId();
setConnections((prev) => {
const nextConnection = {
id: connectionId,
provider,
email: payload.email,
label: payload.label,
status: payload.status ?? "pending",
lastSyncedAt: payload.lastSyncedAt,
shareWithFamily: payload.shareWithFamily,
createdAt: payload.createdAt,
scopes: payload.scopes
};
return {
...prev,
[profileId]: [...(prev[profileId] ?? []), nextConnection]
};
});
return connectionId;
}, []);
const replaceConnections = useCallback((profileId, list) => {
setConnections((prev) => ({
...prev,
[profileId]: list
}));
}, []);
const updateConnection = useCallback((profileId, connectionId, updates) => {
setConnections((prev) => {
const existing = prev[profileId];
if (!existing)
return prev;
const next = existing.map((connection) => connection.id === connectionId ? { ...connection, ...updates } : connection);
return { ...prev, [profileId]: next };
});
}, []);
const removeConnection = useCallback((profileId, connectionId) => {
setConnections((prev) => {
const existing = prev[profileId];
if (!existing)
return prev;
const next = existing.filter((connection) => connection.id !== connectionId);
return { ...prev, [profileId]: next };
});
}, []);
const totalConnections = useMemo(() => Object.values(connections).reduce((acc, list) => acc + list.length, 0), [connections]);
return {
connections,
getConnections,
addConnection,
replaceConnections,
updateConnection,
removeConnection,
totalConnections
};
};

View File

@@ -0,0 +1,140 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { CalendarConnectionStatus, CalendarProvider, ConnectedCalendar } from "../types/calendar";
const STORAGE_KEY = "fp:calendarConnections";
type ConnectionsState = Record<string, ConnectedCalendar[]>;
const randomId = () => {
try {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
} catch {
// ignore
}
return `cal_${Math.random().toString(36).slice(2, 11)}`;
};
const readStorage = (): ConnectionsState => {
if (typeof window === "undefined") return {};
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
if (!stored) return {};
const parsed = JSON.parse(stored) as ConnectionsState;
return parsed ?? {};
} catch {
return {};
}
};
const writeStorage = (value: ConnectionsState) => {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(value));
} catch {
// ignore persistence errors
}
};
export const useCalendarIntegrations = () => {
const [connections, setConnections] = useState<ConnectionsState>(() => readStorage());
useEffect(() => {
writeStorage(connections);
}, [connections]);
useEffect(() => {
if (typeof window === "undefined") return;
const handler = (event: StorageEvent) => {
if (event.key !== STORAGE_KEY || event.newValue == null) return;
try {
const parsed = JSON.parse(event.newValue) as ConnectionsState;
setConnections(parsed ?? {});
} catch {
// ignore malformed payload
}
};
window.addEventListener("storage", handler);
return () => window.removeEventListener("storage", handler);
}, []);
const getConnections = useCallback(
(profileId: string) => connections[profileId] ?? [],
[connections]
);
const addConnection = useCallback(
(
profileId: string,
provider: CalendarProvider,
payload: { email: string; label?: string; status?: CalendarConnectionStatus; lastSyncedAt?: string; shareWithFamily?: boolean; createdAt?: string; scopes?: string[] }
) => {
const connectionId = randomId();
setConnections((prev) => {
const nextConnection: ConnectedCalendar = {
id: connectionId,
provider,
email: payload.email,
label: payload.label,
status: payload.status ?? "pending",
lastSyncedAt: payload.lastSyncedAt,
shareWithFamily: payload.shareWithFamily,
createdAt: payload.createdAt,
scopes: payload.scopes
};
return {
...prev,
[profileId]: [...(prev[profileId] ?? []), nextConnection]
};
});
return connectionId;
},
[]
);
const replaceConnections = useCallback((profileId: string, list: ConnectedCalendar[]) => {
setConnections((prev) => ({
...prev,
[profileId]: list
}));
}, []);
const updateConnection = useCallback(
(profileId: string, connectionId: string, updates: Partial<Omit<ConnectedCalendar, "id">>) => {
setConnections((prev) => {
const existing = prev[profileId];
if (!existing) return prev;
const next = existing.map((connection) =>
connection.id === connectionId ? { ...connection, ...updates } : connection
);
return { ...prev, [profileId]: next };
});
},
[]
);
const removeConnection = useCallback((profileId: string, connectionId: string) => {
setConnections((prev) => {
const existing = prev[profileId];
if (!existing) return prev;
const next = existing.filter((connection) => connection.id !== connectionId);
return { ...prev, [profileId]: next };
});
}, []);
const totalConnections = useMemo(
() => Object.values(connections).reduce((acc, list) => acc + list.length, 0),
[connections]
);
return {
connections,
getConnections,
addConnection,
replaceConnections,
updateConnection,
removeConnection,
totalConnections
};
};

View File

@@ -0,0 +1,25 @@
import { useEffect } from "react";
export const useCalendarOAuthListener = (listener) => {
useEffect(() => {
const handler = (event) => {
if (typeof event.data !== "object" || event.data === null)
return;
const payload = event.data;
if (payload.type !== "fp-calendar-oauth" || typeof payload.state !== "string")
return;
listener({
type: "fp-calendar-oauth",
state: payload.state,
success: Boolean(payload.success),
provider: payload.provider,
email: payload.email,
label: payload.label,
error: payload.error,
profileId: payload.profileId,
connectionId: payload.connectionId
});
};
window.addEventListener("message", handler);
return () => window.removeEventListener("message", handler);
}, [listener]);
};

View File

@@ -0,0 +1,39 @@
import { useEffect } from "react";
import { CalendarProvider } from "../types/calendar";
type OAuthMessage = {
type: "fp-calendar-oauth";
state: string;
success: boolean;
provider?: CalendarProvider;
email?: string;
label?: string;
error?: string;
profileId?: string;
connectionId?: string;
};
type OAuthListener = (message: OAuthMessage) => void;
export const useCalendarOAuthListener = (listener: OAuthListener) => {
useEffect(() => {
const handler = (event: MessageEvent) => {
if (typeof event.data !== "object" || event.data === null) return;
const payload = event.data as Partial<OAuthMessage>;
if (payload.type !== "fp-calendar-oauth" || typeof payload.state !== "string") return;
listener({
type: "fp-calendar-oauth",
state: payload.state,
success: Boolean(payload.success),
provider: payload.provider,
email: payload.email,
label: payload.label,
error: payload.error,
profileId: payload.profileId,
connectionId: payload.connectionId
});
};
window.addEventListener("message", handler);
return () => window.removeEventListener("message", handler);
}, [listener]);
};

View File

@@ -0,0 +1,32 @@
import { createGlobalStyle } from "styled-components";
export const GlobalStyle = createGlobalStyle `
:root {
color-scheme: light dark;
--brand-primary: #5562ff;
--brand-secondary: #ff7a59;
--brand-background: #0c1022;
--brand-surface: rgba(20, 25, 45, 0.86);
--text-primary: #f5f6fa;
--text-muted: #b2b7d0;
}
* {
box-sizing: border-box;
}
html, body, #root {
height: 100%;
}
body {
margin: 0;
font-family: "Segoe UI", "Roboto", sans-serif;
background: radial-gradient(circle at top, rgba(85, 98, 255, 0.2), transparent 55%), linear-gradient(135deg, #0c1022, #141f3b 55%, #1f1a3b);
color: var(--text-primary);
}
a {
color: inherit;
text-decoration: none;
}
`;

View File

@@ -0,0 +1,33 @@
import { createGlobalStyle } from "styled-components";
export const GlobalStyle = createGlobalStyle`
:root {
color-scheme: light dark;
--brand-primary: #5562ff;
--brand-secondary: #ff7a59;
--brand-background: #0c1022;
--brand-surface: rgba(20, 25, 45, 0.86);
--text-primary: #f5f6fa;
--text-muted: #b2b7d0;
}
* {
box-sizing: border-box;
}
html, body, #root {
height: 100%;
}
body {
margin: 0;
font-family: "Segoe UI", "Roboto", sans-serif;
background: radial-gradient(circle at top, rgba(85, 98, 255, 0.2), transparent 55%), linear-gradient(135deg, #0c1022, #141f3b 55%, #1f1a3b);
color: var(--text-primary);
}
a {
color: inherit;
text-decoration: none;
}
`;

View File

@@ -0,0 +1,31 @@
import "@testing-library/jest-dom";
import { afterEach, vi } from "vitest";
import { cleanup } from "@testing-library/react";
// Cleanup after each test
afterEach(() => {
cleanup();
});
// Mock window.matchMedia
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn()
}))
});
// Mock IntersectionObserver
global.IntersectionObserver = class IntersectionObserver {
constructor() { }
disconnect() { }
observe() { }
takeRecords() {
return [];
}
unobserve() { }
};

View File

@@ -0,0 +1,34 @@
import "@testing-library/jest-dom";
import { expect, afterEach, vi } from "vitest";
import { cleanup } from "@testing-library/react";
// Cleanup after each test
afterEach(() => {
cleanup();
});
// Mock window.matchMedia
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn()
}))
});
// Mock IntersectionObserver
global.IntersectionObserver = class IntersectionObserver {
constructor() {}
disconnect() {}
observe() {}
takeRecords() {
return [];
}
unobserve() {}
} as unknown as typeof IntersectionObserver;

View File

@@ -0,0 +1,5 @@
/**
* API response types
* These types define the structure of responses from the backend API
*/
export {};

78
frontend/src/types/api.ts Normal file
View File

@@ -0,0 +1,78 @@
/**
* API response types
* These types define the structure of responses from the backend API
*/
export type ScheduleStatus = "pending" | "processing" | "ready" | "failed";
export type Activity = {
id: string;
title: string;
start: string;
end: string;
location?: string;
notes?: string;
category?: string;
color?: string;
};
export type Schedule = {
id: string;
childId: string;
status: ScheduleStatus;
activities: Activity[];
sourceFileUrl?: string;
periodStart?: string;
periodEnd?: string;
createdAt: string;
updatedAt: string;
};
export type ScheduleUploadResponse = {
schedule: Schedule;
message?: string;
};
export type IngestionStatusData = {
status: "healthy" | "unhealthy" | "unknown";
message?: string;
timestamp: string;
};
export type IngestionStatusResponse = {
ok: boolean;
data?: IngestionStatusData;
};
export type IngestionConfigResponse = {
openaiConfigured: boolean;
model: string | null;
};
export type DiagnoseUploadResponse = {
filename: string;
mimetype: string;
detectedType: string;
analysisPlan: string;
wouldUseVision: boolean;
ingestionHealthy: boolean;
};
export type RepairIngestionResponse = {
ok: boolean;
steps?: string[];
message?: string;
};
export type ApiErrorResponse = {
error: string;
message: string;
details?: unknown;
timestamp: string;
};
export type ApiSuccessResponse<T = void> = {
ok: boolean;
data?: T;
message?: string;
};

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1,15 @@
export type CalendarProvider = "google" | "outlook";
export type CalendarConnectionStatus = "connected" | "pending" | "error";
export type ConnectedCalendar = {
id: string;
provider: CalendarProvider;
email: string;
label?: string;
status: CalendarConnectionStatus;
lastSyncedAt?: string;
createdAt?: string;
scopes?: string[];
shareWithFamily?: boolean;
};

16
frontend/src/types/global.d.ts vendored Normal file
View File

@@ -0,0 +1,16 @@
/**
* Global type declarations for the Family Planner application
*/
export {};
declare global {
interface Window {
/**
* Toast notification bridge for non-React contexts
* @param success - Whether the toast should be a success or error
* @param message - The message to display
*/
__fp_toast?: (success: boolean, message: string) => void;
}
}

View File

@@ -0,0 +1,80 @@
const STORAGE_KEY = "fp:calendarPendingOAuth";
const RESULT_KEY = "fp:calendarOAuthResult";
const readPending = () => {
if (typeof window === "undefined")
return {};
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw)
return {};
const parsed = JSON.parse(raw);
return parsed ?? {};
}
catch {
return {};
}
};
const writePending = (value) => {
if (typeof window === "undefined")
return;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(value));
}
catch {
// ignore storage errors
}
};
export const savePendingOAuthState = (entry) => {
const pending = readPending();
pending[entry.state] = entry;
writePending(pending);
};
export const consumePendingOAuthState = (state) => {
const pending = readPending();
const entry = pending[state];
if (!entry)
return null;
delete pending[state];
writePending(pending);
return entry;
};
export const peekPendingOAuthState = (state) => {
const pending = readPending();
return pending[state] ?? null;
};
export const clearOAuthResult = () => {
if (typeof window === "undefined")
return;
try {
window.localStorage.removeItem(RESULT_KEY);
}
catch {
// ignore
}
};
export const storeOAuthResult = (payload) => {
if (typeof window === "undefined")
return;
try {
window.localStorage.setItem(RESULT_KEY, JSON.stringify({
timestamp: Date.now(),
payload
}));
}
catch {
// ignore
}
};
export const readOAuthResult = () => {
if (typeof window === "undefined")
return null;
try {
const raw = window.localStorage.getItem(RESULT_KEY);
if (!raw)
return null;
return JSON.parse(raw);
}
catch {
return null;
}
};

View File

@@ -0,0 +1,89 @@
import { CalendarProvider } from "../types/calendar";
const STORAGE_KEY = "fp:calendarPendingOAuth";
const RESULT_KEY = "fp:calendarOAuthResult";
export type PendingOAuthState = {
state: string;
profileId: string;
provider: CalendarProvider;
connectionId: string;
};
type PendingCollection = Record<string, PendingOAuthState>;
const readPending = (): PendingCollection => {
if (typeof window === "undefined") return {};
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw) as PendingCollection;
return parsed ?? {};
} catch {
return {};
}
};
const writePending = (value: PendingCollection) => {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(value));
} catch {
// ignore storage errors
}
};
export const savePendingOAuthState = (entry: PendingOAuthState) => {
const pending = readPending();
pending[entry.state] = entry;
writePending(pending);
};
export const consumePendingOAuthState = (state: string): PendingOAuthState | null => {
const pending = readPending();
const entry = pending[state];
if (!entry) return null;
delete pending[state];
writePending(pending);
return entry;
};
export const peekPendingOAuthState = (state: string): PendingOAuthState | null => {
const pending = readPending();
return pending[state] ?? null;
};
export const clearOAuthResult = () => {
if (typeof window === "undefined") return;
try {
window.localStorage.removeItem(RESULT_KEY);
} catch {
// ignore
}
};
export const storeOAuthResult = (payload: unknown) => {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(
RESULT_KEY,
JSON.stringify({
timestamp: Date.now(),
payload
})
);
} catch {
// ignore
}
};
export const readOAuthResult = (): { timestamp: number; payload: any } | null => {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(RESULT_KEY);
if (!raw) return null;
return JSON.parse(raw) as { timestamp: number; payload: any };
} catch {
return null;
}
};