feat: improve event logging UX

- Remove quick-template feature from dashboard
- Increase photo upload limit from 5MB to 30MB
- Add combined diaper toggle (wet + stool in one save) to event modal
- Show all event types in quick-add FAB and dashboard quick-log grid
- Add OTHER generic event type for freeform notes (schema + migration)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 22:01:05 +02:00
parent 2d0c44e21d
commit 6f2336ffae
7 changed files with 94 additions and 124 deletions
@@ -0,0 +1,37 @@
-- AlterEnum
ALTER TYPE "EventType" ADD VALUE 'OTHER';
-- CreateTable
CREATE TABLE "PendingInvite" (
"id" TEXT NOT NULL,
"familyId" TEXT NOT NULL,
"email" TEXT NOT NULL,
"role" "UserRole" NOT NULL DEFAULT 'PARENT',
"token" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PendingInvite_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Webhook" (
"id" TEXT NOT NULL,
"familyId" TEXT NOT NULL,
"url" TEXT NOT NULL,
"secret" TEXT NOT NULL,
"events" TEXT[],
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Webhook_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "PendingInvite_token_key" ON "PendingInvite"("token");
-- AddForeignKey
ALTER TABLE "PendingInvite" ADD CONSTRAINT "PendingInvite_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "Family"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "Family"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+1
View File
@@ -193,6 +193,7 @@ enum EventType {
WALK
TUMMY_TIME
SYMPTOM
OTHER
}
model GrowthLog {
+5 -121
View File
@@ -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, Pin, PinOff, Pencil, Check, X, Plus, Trash2, 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";
const QUICK_LOG_GROUPS: { label: string; types: EventType[] }[] = [
@@ -25,6 +25,10 @@ const QUICK_LOG_GROUPS: { label: string; types: EventType[] }[] = [
label: "Santé",
types: ["TEMPERATURE", "MEDICATION", "SYMPTOM"],
},
{
label: "Autre",
types: ["OTHER"],
},
];
interface Event {
@@ -37,12 +41,6 @@ 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);
@@ -384,115 +382,6 @@ function PinnedNoteWidget() {
);
}
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();
@@ -809,11 +698,6 @@ export default function DashboardPage() {
))}
</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]);
+2 -2
View File
@@ -5,7 +5,7 @@ import { join } from "path";
import { randomUUID } from "crypto";
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"];
const MAX_SIZE = 5 * 1024 * 1024; // 5 MB
const MAX_SIZE = 30 * 1024 * 1024; // 30 MB
// Configurable via env so Docker volume can be mounted at /data/uploads
const UPLOAD_DIR = process.env.UPLOAD_DIR ?? join(process.cwd(), "public", "uploads");
@@ -19,7 +19,7 @@ export async function POST(req: Request) {
if (!file) return NextResponse.json({ error: "Fichier manquant" }, { status: 400 });
if (!ALLOWED_TYPES.includes(file.type)) return NextResponse.json({ error: "Type de fichier non supporté" }, { status: 400 });
if (file.size > MAX_SIZE) return NextResponse.json({ error: "Fichier trop volumineux (max 5 MB)" }, { status: 400 });
if (file.size > MAX_SIZE) return NextResponse.json({ error: "Fichier trop volumineux (max 30 MB)" }, { status: 400 });
const ext = file.type.split("/")[1].replace("jpeg", "jpg");
const filename = `${randomUUID()}.${ext}`;
+32
View File
@@ -140,6 +140,9 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
const [milkStocks, setMilkStocks] = useState<MilkStock[]>([]);
const [selectedStockId, setSelectedStockId] = useState<string | null>(null);
// Combined diaper toggle
const [diaperBoth, setDiaperBoth] = useState(false);
// Pump → stock state
const [addToStock, setAddToStock] = useState(false);
const [pumpStockLocation, setPumpStockLocation] = useState<"room" | "fridge" | "freezer">("fridge");
@@ -358,6 +361,21 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
metadata: Object.keys(metadata).length > 0 ? metadata : null,
}),
});
if (diaperBoth && (type === "DIAPER_WET" || type === "DIAPER_STOOL")) {
const secondType = type === "DIAPER_WET" ? "DIAPER_STOOL" : "DIAPER_WET";
await fetch("/api/events", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
babyId,
type: secondType,
startedAt: toUTC(form.startedAt),
endedAt: null,
notes: null,
metadata: null,
}),
});
}
}
if (type === "BOTTLE" && selectedStockId && form.bottleType === "maternal") {
await fetch(`/api/milk/${selectedStockId}`, {
@@ -637,6 +655,20 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
</div>
)}
{/* Combined diaper toggle */}
{(type === "DIAPER_WET" || type === "DIAPER_STOOL") && !isEditing && (
<button
type="button"
onClick={() => setDiaperBoth((v) => !v)}
className="w-full flex items-center justify-between px-3 py-2.5 rounded-xl border border-slate-200 dark:border-slate-600 text-sm font-medium text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition"
>
<span>{type === "DIAPER_WET" ? "Aussi des selles" : "Aussi mouillée"}</span>
<div className={`w-9 h-5 rounded-full transition-colors ${diaperBoth ? "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 ${diaperBoth ? "translate-x-4" : "translate-x-0.5"}`} />
</div>
</button>
)}
{/* Medication — profile picker */}
{type === "MEDICATION" && (
<div className="space-y-3">
+5
View File
@@ -21,6 +21,11 @@ const QUICK_TYPES: EventType[] = [
"NIGHT_SLEEP",
"MEDICATION",
"TEMPERATURE",
"BATH",
"WALK",
"TUMMY_TIME",
"SYMPTOM",
"OTHER",
];
export function QuickAddFab() {
+12 -1
View File
@@ -11,7 +11,8 @@ export type EventType =
| "BATH"
| "WALK"
| "TUMMY_TIME"
| "SYMPTOM";
| "SYMPTOM"
| "OTHER";
export const EVENT_CONFIG: Record<
EventType,
@@ -164,6 +165,16 @@ export const EVENT_CONFIG: Record<
hasDuration: false,
hasTimer: false,
},
OTHER: {
label: "Autre",
icon: "FileText",
color: "#64748b",
colorClass: "text-slate-500",
bgClass: "bg-slate-100 dark:bg-slate-800",
borderClass: "border-slate-200 dark:border-slate-700",
hasDuration: false,
hasTimer: false,
},
};
export const ALL_EVENT_TYPES = Object.keys(EVENT_CONFIG) as EventType[];