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>
189 lines
7.1 KiB
TypeScript
189 lines
7.1 KiB
TypeScript
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>
|
|
);
|
|
};
|