feat: per-baby breastfeeding and pump feature toggles

Add isBreastfed and hasPump flags to Baby model. When disabled,
hide breastfeeding/pump options from quick-add FAB, dashboard
counters and quick-log groups, and the milk stock nav link.
Settings page gains toggle UI to configure per baby.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 11:51:41 +02:00
parent 319d70c3a4
commit f2cc181ece
8 changed files with 67 additions and 28 deletions
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Baby" ADD COLUMN "hasPump" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "isBreastfed" BOOLEAN NOT NULL DEFAULT true;
+2
View File
@@ -150,6 +150,8 @@ model Baby {
name String name String
birthDate DateTime birthDate DateTime
gender String? gender String?
isBreastfed Boolean @default(true)
hasPump Boolean @default(true)
familyId String familyId String
family Family @relation(fields: [familyId], references: [id]) family Family @relation(fields: [familyId], references: [id])
events Event[] events Event[]
+21 -21
View File
@@ -12,24 +12,21 @@ import { fr } from "date-fns/locale";
import { AlertTriangle, Clock, CheckCircle2, Pin, PinOff, Pencil, Check, X, Plus, FileText, ImageIcon } from "lucide-react"; import { AlertTriangle, Clock, CheckCircle2, Pin, PinOff, Pencil, Check, X, Plus, FileText, ImageIcon } from "lucide-react";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
const QUICK_LOG_GROUPS: { label: string; types: EventType[] }[] = [ function buildQuickLogGroups(isBreastfed: boolean, hasPump: boolean): { label: string; types: EventType[] }[] {
{ const feedTypes: EventType[] = [
label: "Alimentation & Soins", ...(isBreastfed ? (["BREASTFEED"] as EventType[]) : []),
types: ["BREASTFEED", "BOTTLE", "PUMP", "DIAPER_WET", "DIAPER_STOOL"], "BOTTLE",
}, ...(hasPump ? (["PUMP"] as EventType[]) : []),
{ "DIAPER_WET",
label: "Sommeil & Activités", "DIAPER_STOOL",
types: ["NAP", "NIGHT_SLEEP", "BATH", "WALK", "TUMMY_TIME"],
},
{
label: "Santé",
types: ["TEMPERATURE", "MEDICATION", "SYMPTOM"],
},
{
label: "Autre",
types: ["OTHER"],
},
]; ];
return [
{ label: "Alimentation & Soins", types: feedTypes },
{ label: "Sommeil & Activités", types: ["NAP", "NIGHT_SLEEP", "BATH", "WALK", "TUMMY_TIME"] },
{ label: "Santé", types: ["TEMPERATURE", "MEDICATION", "SYMPTOM"] },
{ label: "Autre", types: ["OTHER"] },
];
}
interface Event { interface Event {
id: string; id: string;
@@ -434,7 +431,7 @@ export default function DashboardPage() {
queryKey: ["events-feeds", selectedBaby?.id], queryKey: ["events-feeds", selectedBaby?.id],
queryFn: () => queryFn: () =>
fetch(`/api/events?babyId=${selectedBaby!.id}&limit=100&type=BREASTFEED`).then((r) => r.json()), fetch(`/api/events?babyId=${selectedBaby!.id}&limit=100&type=BREASTFEED`).then((r) => r.json()),
enabled: !!selectedBaby?.id, enabled: !!selectedBaby?.id && (selectedBaby?.isBreastfed ?? true),
staleTime: 60_000, staleTime: 60_000,
}); });
const { data: bottleData } = useQuery({ const { data: bottleData } = useQuery({
@@ -577,8 +574,11 @@ export default function DashboardPage() {
</div> </div>
{/* Today's summary counters */} {/* Today's summary counters */}
<div className="grid grid-cols-4 gap-3 mb-6"> <div className={`grid gap-3 mb-6 ${selectedBaby.isBreastfed ? "grid-cols-4" : "grid-cols-3"}`}>
{(["BREASTFEED", "BOTTLE", "DIAPER_WET", "DIAPER_STOOL"] as EventType[]).map((t) => { {(["BREASTFEED", "BOTTLE", "DIAPER_WET", "DIAPER_STOOL"] as EventType[]).filter((t) => {
if (t === "BREASTFEED" && !selectedBaby.isBreastfed) return false;
return true;
}).map((t) => {
const cfg = EVENT_CONFIG[t]; const cfg = EVENT_CONFIG[t];
return ( return (
<div key={t} className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-3 text-center"> <div key={t} className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-3 text-center">
@@ -673,7 +673,7 @@ export default function DashboardPage() {
{/* Quick log */} {/* Quick log */}
<div className="space-y-4"> <div className="space-y-4">
{QUICK_LOG_GROUPS.map((group) => ( {buildQuickLogGroups(selectedBaby.isBreastfed, selectedBaby.hasPump).map((group) => (
<div key={group.label}> <div key={group.label}>
<h2 className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-2">{group.label}</h2> <h2 className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-2">{group.label}</h2>
<div className="grid grid-cols-4 sm:grid-cols-6 gap-2"> <div className="grid grid-cols-4 sm:grid-cols-6 gap-2">
+23 -2
View File
@@ -438,7 +438,7 @@ export default function SettingsPage() {
const [dateTo, setDateTo] = useState(""); const [dateTo, setDateTo] = useState("");
const [editingBaby, setEditingBaby] = useState(false); const [editingBaby, setEditingBaby] = useState(false);
const [babyForm, setBabyForm] = useState({ name: "", birthDate: "", gender: "" }); const [babyForm, setBabyForm] = useState({ name: "", birthDate: "", gender: "", isBreastfed: true, hasPump: true });
const [savingBaby, setSavingBaby] = useState(false); const [savingBaby, setSavingBaby] = useState(false);
const [thresholds, setThresholdsState] = useState(getThresholds); const [thresholds, setThresholdsState] = useState(getThresholds);
@@ -563,13 +563,15 @@ export default function SettingsPage() {
setExportLoading(false); setExportLoading(false);
} }
function startEditBaby(b?: { name: string; birthDate: string; gender?: string }) { function startEditBaby(b?: { name: string; birthDate: string; gender?: string; isBreastfed?: boolean; hasPump?: boolean }) {
const src = b ?? selectedBaby; const src = b ?? selectedBaby;
if (!src) return; if (!src) return;
setBabyForm({ setBabyForm({
name: src.name, name: src.name,
birthDate: src.birthDate.slice(0, 10), birthDate: src.birthDate.slice(0, 10),
gender: src.gender ?? "", gender: src.gender ?? "",
isBreastfed: src.isBreastfed !== false,
hasPump: src.hasPump !== false,
}); });
setEditingBaby(true); setEditingBaby(true);
} }
@@ -696,6 +698,25 @@ export default function SettingsPage() {
<option value="girl">Fille</option> <option value="girl">Fille</option>
</select> </select>
</div> </div>
<div className="space-y-2">
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400">Alimentation</label>
{[
{ key: "isBreastfed" as const, label: "Allaitement maternel" },
{ key: "hasPump" as const, label: "Tire-lait & stock lait" },
].map(({ key, label }) => (
<button
key={key}
type="button"
onClick={() => setBabyForm((f) => ({ ...f, [key]: !f[key] }))}
className="w-full flex items-center justify-between px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 text-sm text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition"
>
<span>{label}</span>
<div className={`w-9 h-5 rounded-full transition-colors ${babyForm[key] ? "bg-indigo-600" : "bg-slate-200 dark:bg-slate-600"} relative flex-shrink-0`}>
<div className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${babyForm[key] ? "translate-x-4" : "translate-x-0.5"}`} />
</div>
</button>
))}
</div>
<div className="flex gap-2"> <div className="flex gap-2">
<button type="button" onClick={() => setEditingBaby(false)} className="flex-1 py-2 border border-slate-200 dark:border-slate-700 rounded-lg text-sm text-slate-600 dark:text-slate-400 bg-white dark:bg-slate-700">Annuler</button> <button type="button" onClick={() => setEditingBaby(false)} className="flex-1 py-2 border border-slate-200 dark:border-slate-700 rounded-lg text-sm text-slate-600 dark:text-slate-400 bg-white dark:bg-slate-700">Annuler</button>
<button type="submit" disabled={savingBaby} className="flex-1 py-2 bg-indigo-600 text-white rounded-lg text-sm font-medium"> <button type="submit" disabled={savingBaby} className="flex-1 py-2 bg-indigo-600 text-white rounded-lg text-sm font-medium">
+3 -1
View File
@@ -10,7 +10,7 @@ export async function PATCH(
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { id } = await params; const { id } = await params;
const { name, birthDate, gender } = await req.json(); const { name, birthDate, gender, isBreastfed, hasPump } = await req.json();
const familyId = (session.user as { familyId?: string }).familyId; const familyId = (session.user as { familyId?: string }).familyId;
if (!familyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); if (!familyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
@@ -25,6 +25,8 @@ export async function PATCH(
...(name ? { name } : {}), ...(name ? { name } : {}),
...(birthDate ? { birthDate: new Date(birthDate) } : {}), ...(birthDate ? { birthDate: new Date(birthDate) } : {}),
...(gender !== undefined ? { gender } : {}), ...(gender !== undefined ? { gender } : {}),
...(isBreastfed !== undefined ? { isBreastfed } : {}),
...(hasPump !== undefined ? { hasPump } : {}),
}, },
}); });
+3
View File
@@ -186,6 +186,7 @@ export function SidebarNav() {
{/* Grouped links */} {/* Grouped links */}
{DRAWER_GROUPS.map((group) => { {DRAWER_GROUPS.map((group) => {
const groupLinks = group.hrefs const groupLinks = group.hrefs
.filter((href) => !(href === "/milk" && !selectedBaby?.hasPump))
.map((href) => LINKS.find((l) => l.href === href)) .map((href) => LINKS.find((l) => l.href === href))
.filter(Boolean) as typeof LINKS; .filter(Boolean) as typeof LINKS;
if (groupLinks.length === 0) return null; if (groupLinks.length === 0) return null;
@@ -265,6 +266,7 @@ export function BottomNav() {
const { data: session } = useSession(); const { data: session } = useSession();
const isSuperAdmin = (session?.user as { isSuperAdmin?: boolean })?.isSuperAdmin; const isSuperAdmin = (session?.user as { isSuperAdmin?: boolean })?.isSuperAdmin;
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const { selectedBaby } = useBaby();
const secondaryLinks = isSuperAdmin const secondaryLinks = isSuperAdmin
? [...SECONDARY_LINKS, { href: "/admin", label: "Admin", Icon: ShieldCheck }] ? [...SECONDARY_LINKS, { href: "/admin", label: "Admin", Icon: ShieldCheck }]
@@ -327,6 +329,7 @@ export function BottomNav() {
<div className="px-4 pb-4 space-y-4"> <div className="px-4 pb-4 space-y-4">
{DRAWER_GROUPS.map((group) => { {DRAWER_GROUPS.map((group) => {
const groupLinks = group.hrefs const groupLinks = group.hrefs
.filter((href) => !(href === "/milk" && !selectedBaby?.hasPump))
.map((href) => secondaryLinks.find((l) => l.href === href)) .map((href) => secondaryLinks.find((l) => l.href === href))
.filter(Boolean) as typeof secondaryLinks; .filter(Boolean) as typeof secondaryLinks;
if (groupLinks.length === 0) return null; if (groupLinks.length === 0) return null;
+8 -2
View File
@@ -11,7 +11,7 @@ import { Plus, X } from "lucide-react";
const FAB_HIDDEN_PATHS = ["/settings", "/admin"]; const FAB_HIDDEN_PATHS = ["/settings", "/admin"];
const QUICK_TYPES: EventType[] = [ const ALL_QUICK_TYPES: EventType[] = [
"BREASTFEED", "BREASTFEED",
"BOTTLE", "BOTTLE",
"PUMP", "PUMP",
@@ -32,6 +32,12 @@ export function QuickAddFab() {
const qc = useQueryClient(); const qc = useQueryClient();
const { selectedBaby } = useBaby(); const { selectedBaby } = useBaby();
const pathname = usePathname(); const pathname = usePathname();
const quickTypes = ALL_QUICK_TYPES.filter((t) => {
if (t === "BREASTFEED" && !selectedBaby?.isBreastfed) return false;
if (t === "PUMP" && !selectedBaby?.hasPump) return false;
return true;
});
const [sheetOpen, setSheetOpen] = useState(false); const [sheetOpen, setSheetOpen] = useState(false);
const [activeModal, setActiveModal] = useState<EventType | null>(null); const [activeModal, setActiveModal] = useState<EventType | null>(null);
@@ -75,7 +81,7 @@ export function QuickAddFab() {
</button> </button>
</div> </div>
<div className="grid grid-cols-5 gap-2 px-4 pb-2"> <div className="grid grid-cols-5 gap-2 px-4 pb-2">
{QUICK_TYPES.map((type) => { {quickTypes.map((type) => {
const cfg = EVENT_CONFIG[type]; const cfg = EVENT_CONFIG[type];
return ( return (
<button <button
+2
View File
@@ -8,6 +8,8 @@ interface Baby {
name: string; name: string;
birthDate: string; birthDate: string;
gender?: string; gender?: string;
isBreastfed: boolean;
hasPump: boolean;
} }
interface Family { interface Family {