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:
17
frontend/src/App.js
Normal file
17
frontend/src/App.js
Normal 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
42
frontend/src/App.tsx
Normal 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;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
77
frontend/src/components/ChildCard.js
Normal file
77
frontend/src/components/ChildCard.js
Normal 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 })] }));
|
||||
};
|
||||
133
frontend/src/components/ChildCard.tsx
Normal file
133
frontend/src/components/ChildCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
394
frontend/src/components/ChildProfilePanel.js
Normal file
394
frontend/src/components/ChildProfilePanel.js
Normal 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] })] })] }));
|
||||
};
|
||||
604
frontend/src/components/ChildProfilePanel.tsx
Normal file
604
frontend/src/components/ChildProfilePanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
115
frontend/src/components/DailyScheduleGrid.js
Normal file
115
frontend/src/components/DailyScheduleGrid.js
Normal 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))) }))] }));
|
||||
};
|
||||
200
frontend/src/components/DailyScheduleGrid.tsx
Normal file
200
frontend/src/components/DailyScheduleGrid.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
119
frontend/src/components/ErrorBoundary.js
Normal file
119
frontend/src/components/ErrorBoundary.js
Normal 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;
|
||||
}
|
||||
}
|
||||
39
frontend/src/components/ErrorBoundary.test.js
Normal file
39
frontend/src/components/ErrorBoundary.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
66
frontend/src/components/ErrorBoundary.test.tsx
Normal file
66
frontend/src/components/ErrorBoundary.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
171
frontend/src/components/ErrorBoundary.tsx
Normal file
171
frontend/src/components/ErrorBoundary.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
88
frontend/src/components/Layout.js
Normal file
88
frontend/src/components/Layout.js
Normal 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 })] }));
|
||||
};
|
||||
135
frontend/src/components/Layout.tsx
Normal file
135
frontend/src/components/Layout.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
324
frontend/src/components/ParentProfilePanel.js
Normal file
324
frontend/src/components/ParentProfilePanel.js
Normal 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] })] })] }));
|
||||
};
|
||||
504
frontend/src/components/ParentProfilePanel.tsx
Normal file
504
frontend/src/components/ParentProfilePanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
401
frontend/src/components/PlanningIntegrationDialog.js
Normal file
401
frontend/src/components/PlanningIntegrationDialog.js
Normal 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" }) })] }) }));
|
||||
};
|
||||
689
frontend/src/components/PlanningIntegrationDialog.tsx
Normal file
689
frontend/src/components/PlanningIntegrationDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
67
frontend/src/components/ProfileActionBar.js
Normal file
67
frontend/src/components/ProfileActionBar.js
Normal 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] }));
|
||||
};
|
||||
126
frontend/src/components/ProfileActionBar.tsx
Normal file
126
frontend/src/components/ProfileActionBar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
51
frontend/src/components/SampleScheduleGrid.js
Normal file
51
frontend/src/components/SampleScheduleGrid.js
Normal 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." }))] }));
|
||||
};
|
||||
95
frontend/src/components/SampleScheduleGrid.tsx
Normal file
95
frontend/src/components/SampleScheduleGrid.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
238
frontend/src/components/TimeGridMulti.js
Normal file
238
frontend/src/components/TimeGridMulti.js
Normal 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;
|
||||
`;
|
||||
366
frontend/src/components/TimeGridMulti.tsx
Normal file
366
frontend/src/components/TimeGridMulti.tsx
Normal 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;
|
||||
`;
|
||||
|
||||
69
frontend/src/components/ToastProvider.js
Normal file
69
frontend/src/components/ToastProvider.js
Normal 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;
|
||||
};
|
||||
96
frontend/src/components/ToastProvider.tsx
Normal file
96
frontend/src/components/ToastProvider.tsx
Normal 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;
|
||||
};
|
||||
86
frontend/src/components/UpcomingAlerts.js
Normal file
86
frontend/src/components/UpcomingAlerts.js
Normal 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))) }))] }));
|
||||
};
|
||||
131
frontend/src/components/UpcomingAlerts.tsx
Normal file
131
frontend/src/components/UpcomingAlerts.tsx
Normal 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
10
frontend/src/main.js
Normal 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
23
frontend/src/main.tsx
Normal 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>
|
||||
);
|
||||
20
frontend/src/screens/AddChildScreen.js
Normal file
20
frontend/src/screens/AddChildScreen.js
Normal 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, {})] }));
|
||||
};
|
||||
30
frontend/src/screens/AddChildScreen.tsx
Normal file
30
frontend/src/screens/AddChildScreen.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
35
frontend/src/screens/AddPersonScreen.js
Normal file
35
frontend/src/screens/AddPersonScreen.js
Normal 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" })] }));
|
||||
};
|
||||
70
frontend/src/screens/AddPersonScreen.tsx
Normal file
70
frontend/src/screens/AddPersonScreen.tsx
Normal 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 grand‑parent. 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")}>
|
||||
Grand‑parent
|
||||
</ToggleBtn>
|
||||
</Toggle>
|
||||
{mode === "child" ? <ChildProfilePanel /> : <ParentProfilePanel kind={mode === "parent" ? "parent" : "grandparent"} />}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
359
frontend/src/screens/AdultDetailScreen.js
Normal file
359
frontend/src/screens/AdultDetailScreen.js
Normal 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))) }))] })] })] }));
|
||||
};
|
||||
544
frontend/src/screens/AdultDetailScreen.tsx
Normal file
544
frontend/src/screens/AdultDetailScreen.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
151
frontend/src/screens/CalendarOAuthCallbackScreen.js
Normal file
151
frontend/src/screens/CalendarOAuthCallbackScreen.js
Normal 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] }) }));
|
||||
};
|
||||
189
frontend/src/screens/CalendarOAuthCallbackScreen.tsx
Normal file
189
frontend/src/screens/CalendarOAuthCallbackScreen.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1030
frontend/src/screens/ChildDetailScreen.tsx
Normal file
1030
frontend/src/screens/ChildDetailScreen.tsx
Normal file
File diff suppressed because it is too large
Load Diff
142
frontend/src/screens/ChildPlanningScreen.js
Normal file
142
frontend/src/screens/ChildPlanningScreen.js
Normal 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() }))] }));
|
||||
};
|
||||
188
frontend/src/screens/ChildPlanningScreen.tsx
Normal file
188
frontend/src/screens/ChildPlanningScreen.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
100
frontend/src/screens/ChildrenScreen.js
Normal file
100
frontend/src/screens/ChildrenScreen.js
Normal 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 l’ouvrir." : ""}`);
|
||||
}
|
||||
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’ d’abord.");
|
||||
}
|
||||
};
|
||||
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] })] }));
|
||||
};
|
||||
151
frontend/src/screens/ChildrenScreen.tsx
Normal file
151
frontend/src/screens/ChildrenScreen.tsx
Normal 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 l’ouvrir." : ""}`
|
||||
);
|
||||
} 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’ d’abord.");
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
439
frontend/src/screens/DashboardScreen.js
Normal file
439
frontend/src/screens/DashboardScreen.js
Normal 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 })] }))] }));
|
||||
};
|
||||
577
frontend/src/screens/DashboardScreen.tsx
Normal file
577
frontend/src/screens/DashboardScreen.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
262
frontend/src/screens/GrandParentDetailScreen.tsx
Normal file
262
frontend/src/screens/GrandParentDetailScreen.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
647
frontend/src/screens/GrandparentDetailScreen.js
Normal file
647
frontend/src/screens/GrandparentDetailScreen.js
Normal 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
|
||||
})
|
||||
]
|
||||
})
|
||||
);
|
||||
};
|
||||
758
frontend/src/screens/MonthlyCalendarScreen.tsx
Normal file
758
frontend/src/screens/MonthlyCalendarScreen.tsx
Normal 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" })}
|
||||
{entry.activity.title}
|
||||
</Item>
|
||||
))}
|
||||
{(byDate[iso]?.length ?? 0) > 4 ? (
|
||||
<Item $color="#888">+{(byDate[iso]?.length ?? 0) - 4} autres</Item>
|
||||
) : null}
|
||||
</Cell>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
647
frontend/src/screens/ParentDetailScreen.js
Normal file
647
frontend/src/screens/ParentDetailScreen.js
Normal 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
|
||||
})
|
||||
]
|
||||
})
|
||||
);
|
||||
};
|
||||
262
frontend/src/screens/ParentDetailScreen.tsx
Normal file
262
frontend/src/screens/ParentDetailScreen.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
416
frontend/src/screens/ParentsScreen.js
Normal file
416
frontend/src/screens/ParentsScreen.js
Normal 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 })] }));
|
||||
};
|
||||
626
frontend/src/screens/ParentsScreen.tsx
Normal file
626
frontend/src/screens/ParentsScreen.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
86
frontend/src/screens/PersonPlanningScreen.js
Normal file
86
frontend/src/screens/PersonPlanningScreen.js
Normal 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] }));
|
||||
};
|
||||
147
frontend/src/screens/PersonPlanningScreen.tsx
Normal file
147
frontend/src/screens/PersonPlanningScreen.tsx
Normal 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 l’intégration directe des
|
||||
agendas Outlook et Gmail afin de synchroniser automatiquement les événements détectés lors de l’import.
|
||||
</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 l’analyse 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 d’importer 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 d’ensemble 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>
|
||||
);
|
||||
};
|
||||
234
frontend/src/screens/SettingsScreen.js
Normal file
234
frontend/src/screens/SettingsScreen.js
Normal 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 d’affichage 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] })] }));
|
||||
};
|
||||
479
frontend/src/screens/SettingsScreen.tsx
Normal file
479
frontend/src/screens/SettingsScreen.tsx
Normal 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 d’auto‑rafraî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 }}>
|
||||
Auto‑rafraî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 d’affichage 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>
|
||||
);
|
||||
};
|
||||
158
frontend/src/services/alert-service.js
Normal file
158
frontend/src/services/alert-service.js
Normal 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);
|
||||
}
|
||||
211
frontend/src/services/alert-service.ts
Normal file
211
frontend/src/services/alert-service.ts
Normal 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
|
||||
);
|
||||
}
|
||||
146
frontend/src/services/api-client.js
Normal file
146
frontend/src/services/api-client.js
Normal 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()}`);
|
||||
};
|
||||
244
frontend/src/services/api-client.ts
Normal file
244
frontend/src/services/api-client.ts
Normal 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()}`);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
112
frontend/src/state/ChildrenContext.js
Normal file
112
frontend/src/state/ChildrenContext.js
Normal 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;
|
||||
};
|
||||
171
frontend/src/state/ChildrenContext.tsx
Normal file
171
frontend/src/state/ChildrenContext.tsx
Normal 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;
|
||||
};
|
||||
13
frontend/src/state/ParentsLocal.js
Normal file
13
frontend/src/state/ParentsLocal.js
Normal 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));
|
||||
}
|
||||
24
frontend/src/state/ParentsLocal.ts
Normal file
24
frontend/src/state/ParentsLocal.ts
Normal 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));
|
||||
}
|
||||
|
||||
116
frontend/src/state/useCalendarIntegrations.js
Normal file
116
frontend/src/state/useCalendarIntegrations.js
Normal 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
|
||||
};
|
||||
};
|
||||
140
frontend/src/state/useCalendarIntegrations.ts
Normal file
140
frontend/src/state/useCalendarIntegrations.ts
Normal 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
|
||||
};
|
||||
};
|
||||
25
frontend/src/state/useCalendarOAuthListener.js
Normal file
25
frontend/src/state/useCalendarOAuthListener.js
Normal 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]);
|
||||
};
|
||||
39
frontend/src/state/useCalendarOAuthListener.ts
Normal file
39
frontend/src/state/useCalendarOAuthListener.ts
Normal 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]);
|
||||
};
|
||||
32
frontend/src/styles/global-style.js
Normal file
32
frontend/src/styles/global-style.js
Normal 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;
|
||||
}
|
||||
`;
|
||||
33
frontend/src/styles/global-style.ts
Normal file
33
frontend/src/styles/global-style.ts
Normal 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;
|
||||
}
|
||||
`;
|
||||
31
frontend/src/test/setup.js
Normal file
31
frontend/src/test/setup.js
Normal 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() { }
|
||||
};
|
||||
34
frontend/src/test/setup.ts
Normal file
34
frontend/src/test/setup.ts
Normal 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;
|
||||
5
frontend/src/types/api.js
Normal file
5
frontend/src/types/api.js
Normal 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
78
frontend/src/types/api.ts
Normal 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;
|
||||
};
|
||||
1
frontend/src/types/calendar.js
Normal file
1
frontend/src/types/calendar.js
Normal file
@@ -0,0 +1 @@
|
||||
export {};
|
||||
15
frontend/src/types/calendar.ts
Normal file
15
frontend/src/types/calendar.ts
Normal 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
16
frontend/src/types/global.d.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
80
frontend/src/utils/calendar-oauth.js
Normal file
80
frontend/src/utils/calendar-oauth.js
Normal 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;
|
||||
}
|
||||
};
|
||||
89
frontend/src/utils/calendar-oauth.ts
Normal file
89
frontend/src/utils/calendar-oauth.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user