feat: maintenance banner, activities log, pinned note, event templates, weekly summary cron

Maintenance mode banner:
- App layout reads SystemConfig maintenance_mode at server render time
- Amber sticky banner for regular users, dimmed indicator for superadmin

Baby activities (3 new event types):
- BATH (Bath icon, sky, timer+duration)
- WALK / Balade (Footprints icon, lime)
- TUMMY_TIME (Baby icon, amber)
- Added to EventType enum, EVENT_CONFIG, event-icon.tsx, dashboard quick-log

Pinned family note:
- Shared across all family members, shown at top of dashboard
- Inline edit with author + timestamp; /api/family/pinned-note GET/PATCH
- pinnedNote + pinnedNoteUpdatedAt + pinnedNoteAuthor fields on Family model

Event templates:
- Named quick-log presets per family (type + label + metadata)
- Dashboard row: tap to open pre-filled EventModal; hover ✕ to delete
- Dropdown to create new template; /api/event-templates GET/POST + /[id] PATCH/DELETE
- EventTemplate model in schema

Weekly summary cron:
- POST /api/cron/weekly-summary — auth via Bearer cron_secret (SystemConfig or env)
- Per family, per baby: feeds count, sleep total+avg, diaper count, latest weight
- Sends Pushover to all family members with pushoverUserKey
- Optional body.familyId to target one family

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 15:08:54 +02:00
parent 020539e2e2
commit 42c580d7e2
10 changed files with 570 additions and 15 deletions
+226 -2
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { EVENT_CONFIG, EventType, formatTimeAgo, formatDuration } from "@/lib/event-config";
import { EventModal } from "@/components/event-modal";
@@ -9,7 +9,7 @@ import { useBaby } from "@/contexts/baby-context";
import { checkFeedAlert, getThresholds } from "@/lib/notifications";
import { format, isToday } from "date-fns";
import { fr } from "date-fns/locale";
import { AlertTriangle, Clock, CheckCircle2 } from "lucide-react";
import { AlertTriangle, Clock, CheckCircle2, Pin, PinOff, Pencil, Check, X, Plus, Trash2 } from "lucide-react";
const QUICK_LOG_TYPES: EventType[] = [
"BREASTFEED",
@@ -21,6 +21,9 @@ const QUICK_LOG_TYPES: EventType[] = [
"NIGHT_SLEEP",
"MEDICATION",
"TEMPERATURE",
"BATH",
"WALK",
"TUMMY_TIME",
];
interface Event {
@@ -33,6 +36,13 @@ interface Event {
user: { id: string; name: string };
}
interface EventTemplate {
id: string;
label: string;
type: EventType;
metadata?: Record<string, unknown> | null;
}
function getBabyAge(birthDate: string): string {
const birth = new Date(birthDate);
const now = new Date();
@@ -63,6 +73,210 @@ function getEventSummary(event: Event): string {
return "";
}
function PinnedNoteWidget() {
const qc = useQueryClient();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState("");
const [saving, setSaving] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { data } = useQuery<{ note: string | null; updatedAt: string | null; author: string | null }>({
queryKey: ["pinned-note"],
queryFn: () => fetch("/api/family/pinned-note").then((r) => r.json()),
});
function startEdit() {
setDraft(data?.note ?? "");
setEditing(true);
setTimeout(() => textareaRef.current?.focus(), 50);
}
async function save() {
setSaving(true);
await fetch("/api/family/pinned-note", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ note: draft }),
});
qc.invalidateQueries({ queryKey: ["pinned-note"] });
setEditing(false);
setSaving(false);
}
const hasNote = !!data?.note;
return (
<div className="bg-amber-50 dark:bg-amber-950/40 border border-amber-200 dark:border-amber-800 rounded-xl p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5">
<Pin className="w-3.5 h-3.5 text-amber-600 dark:text-amber-400" />
<span className="text-xs font-semibold text-amber-700 dark:text-amber-400 uppercase tracking-wide">Note épinglée</span>
</div>
{!editing && (
<button onClick={startEdit}
className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400 hover:text-amber-800 dark:hover:text-amber-200 transition">
<Pencil className="w-3 h-3" />
{hasNote ? "Modifier" : "Ajouter"}
</button>
)}
</div>
{editing ? (
<div className="space-y-2">
<textarea
ref={textareaRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
rows={3}
className="w-full px-3 py-2 rounded-lg border border-amber-300 dark:border-amber-700 text-sm bg-white dark:bg-slate-800 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-amber-400 resize-none"
placeholder="Message pour toute la famille..."
/>
<div className="flex gap-2">
<button onClick={save} disabled={saving}
className="flex items-center gap-1.5 px-3 py-1.5 bg-amber-500 hover:bg-amber-600 text-white text-xs rounded-lg font-medium transition disabled:opacity-50">
<Check className="w-3 h-3" />
{saving ? "..." : "Enregistrer"}
</button>
<button onClick={() => setEditing(false)}
className="flex items-center gap-1.5 px-3 py-1.5 border border-amber-300 dark:border-amber-700 text-amber-700 dark:text-amber-400 text-xs rounded-lg transition hover:bg-amber-100 dark:hover:bg-amber-950">
<X className="w-3 h-3" />
Annuler
</button>
{hasNote && (
<button onClick={async () => { await fetch("/api/family/pinned-note", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ note: "" }) }); qc.invalidateQueries({ queryKey: ["pinned-note"] }); setEditing(false); }}
className="ml-auto flex items-center gap-1 text-xs text-red-400 hover:text-red-600 transition">
<PinOff className="w-3 h-3" />
Supprimer
</button>
)}
</div>
</div>
) : hasNote ? (
<div>
<p className="text-sm text-amber-900 dark:text-amber-100 whitespace-pre-wrap">{data!.note}</p>
{data?.author && (
<p className="text-[10px] text-amber-500 dark:text-amber-500 mt-1.5">
{data.author}{data.updatedAt ? ` · ${format(new Date(data.updatedAt), "d MMM HH:mm", { locale: fr })}` : ""}
</p>
)}
</div>
) : (
<p className="text-xs text-amber-500 dark:text-amber-600 italic">Aucune note. Cliquez sur Ajouter pour laisser un message à la famille.</p>
)}
</div>
);
}
function TemplatesRow({ babyId, onSaved }: { babyId: string; onSaved: () => void }) {
const qc = useQueryClient();
const [activeTemplate, setActiveTemplate] = useState<EventTemplate | null>(null);
const [addingType, setAddingType] = useState<EventType | null>(null);
const [addLabel, setAddLabel] = useState("");
const [deletingId, setDeletingId] = useState<string | null>(null);
const { data: templates = [] } = useQuery<EventTemplate[]>({
queryKey: ["event-templates"],
queryFn: () => fetch("/api/event-templates").then((r) => r.json()),
});
async function deleteTemplate(id: string) {
setDeletingId(id);
await fetch(`/api/event-templates/${id}`, { method: "DELETE" });
qc.invalidateQueries({ queryKey: ["event-templates"] });
setDeletingId(null);
}
async function createTemplate(e: React.FormEvent) {
e.preventDefault();
if (!addingType || !addLabel.trim()) return;
await fetch("/api/event-templates", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: addingType, label: addLabel.trim() }),
});
qc.invalidateQueries({ queryKey: ["event-templates"] });
setAddingType(null);
setAddLabel("");
}
return (
<div>
<div className="flex items-center justify-between mb-3">
<h2 className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider">Modèles rapides</h2>
<div className="relative">
<select
value=""
onChange={(e) => { if (e.target.value) { setAddingType(e.target.value as EventType); setAddLabel(""); } }}
className="text-xs text-indigo-600 dark:text-indigo-400 border-none bg-transparent appearance-none cursor-pointer focus:outline-none pr-1"
>
<option value="">+ Nouveau modèle</option>
{(Object.keys(EVENT_CONFIG) as EventType[]).map((t) => (
<option key={t} value={t}>{EVENT_CONFIG[t].label}</option>
))}
</select>
</div>
</div>
{addingType && (
<form onSubmit={createTemplate} className="flex gap-2 mb-3">
<div className={`w-8 h-8 rounded-lg ${EVENT_CONFIG[addingType].bgClass} flex items-center justify-center flex-shrink-0`}>
<EventIcon name={EVENT_CONFIG[addingType].icon} className={`w-4 h-4 ${EVENT_CONFIG[addingType].colorClass}`} />
</div>
<input
autoFocus
type="text"
value={addLabel}
onChange={(e) => setAddLabel(e.target.value)}
placeholder={`Nom du modèle (ex: Sieste midi)`}
className="flex-1 px-3 py-1.5 rounded-lg border border-slate-200 dark:border-slate-700 text-sm bg-white dark:bg-slate-800 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-indigo-400"
/>
<button type="submit" className="px-3 py-1.5 bg-indigo-600 text-white rounded-lg text-xs font-medium">OK</button>
<button type="button" onClick={() => setAddingType(null)} className="px-2 py-1.5 border border-slate-200 dark:border-slate-700 rounded-lg text-xs text-slate-500"></button>
</form>
)}
{templates.length > 0 && (
<div className="flex gap-2 overflow-x-auto pb-1 no-scrollbar">
{templates.map((t) => {
const cfg = EVENT_CONFIG[t.type];
return (
<div key={t.id} className="flex-shrink-0 flex items-center gap-1 group">
<button
onClick={() => setActiveTemplate(t)}
className={`flex items-center gap-1.5 px-3 py-2 rounded-xl border text-xs font-medium transition active:scale-95 ${cfg.bgClass} border-transparent hover:border-slate-200 dark:hover:border-slate-600`}
>
<EventIcon name={cfg.icon} className={`w-3.5 h-3.5 ${cfg.colorClass}`} />
<span className="text-slate-700 dark:text-slate-200">{t.label}</span>
</button>
<button
onClick={() => deleteTemplate(t.id)}
disabled={deletingId === t.id}
className="w-5 h-5 flex items-center justify-center rounded text-slate-300 hover:text-red-400 dark:text-slate-600 dark:hover:text-red-400 opacity-0 group-hover:opacity-100 transition"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
);
})}
</div>
)}
{templates.length === 0 && !addingType && (
<p className="text-xs text-slate-400 dark:text-slate-500 italic">Aucun modèle. Créez-en un pour accélérer la saisie.</p>
)}
{activeTemplate && (
<EventModal
type={activeTemplate.type}
babyId={babyId}
onClose={() => setActiveTemplate(null)}
onSaved={() => { onSaved(); setActiveTemplate(null); }}
/>
)}
</div>
);
}
export default function DashboardPage() {
const qc = useQueryClient();
const [activeModal, setActiveModal] = useState<EventType | null>(null);
@@ -143,6 +357,11 @@ export default function DashboardPage() {
</div>
</div>
{/* Pinned note */}
<div className="mb-6">
<PinnedNoteWidget />
</div>
{/* Today's summary counters */}
<div className="grid grid-cols-4 gap-3 mb-6">
{(["BREASTFEED", "BOTTLE", "DIAPER_WET", "DIAPER_STOOL"] as EventType[]).map((t) => {
@@ -210,6 +429,11 @@ export default function DashboardPage() {
</div>
</div>
{/* Event templates */}
<div className="mt-6 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4">
<TemplatesRow babyId={selectedBaby.id} onSaved={() => qc.invalidateQueries({ queryKey: ["events"] })} />
</div>
{/* Medication status */}
{medProfiles.length > 0 && (() => {
const activeMeds = medProfiles.filter((p) => lastIntakes[p.name]);
+27 -1
View File
@@ -3,16 +3,42 @@ import { redirect } from "next/navigation";
import { SidebarNav, BottomNav } from "@/components/nav";
import { BabyProvider } from "@/contexts/baby-context";
import { QuickAddFab } from "@/components/quick-add-fab";
import { prisma } from "@/lib/prisma";
import { AlertTriangle } from "lucide-react";
async function getMaintenanceMode(): Promise<boolean> {
try {
const row = await prisma.systemConfig.findUnique({ where: { key: "maintenance_mode" } });
return row?.value === "true";
} catch {
return false;
}
}
export default async function AppLayout({ children }: { children: React.ReactNode }) {
const session = await auth();
if (!session?.user) redirect("/auth/login");
const maintenance = await getMaintenanceMode();
const isSuperAdmin = (session.user as { isSuperAdmin?: boolean })?.isSuperAdmin;
return (
<BabyProvider>
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
{maintenance && (
<div className={`fixed top-0 left-0 right-0 z-[100] px-4 py-2.5 flex items-center gap-2 justify-center text-sm font-medium shadow-md ${
isSuperAdmin
? "bg-amber-400/90 text-amber-900 text-xs py-1.5"
: "bg-amber-500 text-white"
}`}>
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
{isSuperAdmin
? "Mode maintenance actif (visible des utilisateurs)"
: "Maintenance en cours — certaines fonctionnalités peuvent être indisponibles"}
</div>
)}
<SidebarNav />
<main className="app-main min-h-screen">
<main className={`app-main min-h-screen${maintenance ? " pt-10" : ""}`}>
{children}
</main>
<BottomNav />