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:
@@ -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;
|
||||||
@@ -193,6 +193,7 @@ enum EventType {
|
|||||||
WALK
|
WALK
|
||||||
TUMMY_TIME
|
TUMMY_TIME
|
||||||
SYMPTOM
|
SYMPTOM
|
||||||
|
OTHER
|
||||||
}
|
}
|
||||||
|
|
||||||
model GrowthLog {
|
model GrowthLog {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { useBaby } from "@/contexts/baby-context";
|
|||||||
import { checkFeedAlert, getThresholds } from "@/lib/notifications";
|
import { checkFeedAlert, getThresholds } from "@/lib/notifications";
|
||||||
import { format, isToday } from "date-fns";
|
import { format, isToday } from "date-fns";
|
||||||
import { fr } from "date-fns/locale";
|
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";
|
import { useSession } from "next-auth/react";
|
||||||
|
|
||||||
const QUICK_LOG_GROUPS: { label: string; types: EventType[] }[] = [
|
const QUICK_LOG_GROUPS: { label: string; types: EventType[] }[] = [
|
||||||
@@ -25,6 +25,10 @@ const QUICK_LOG_GROUPS: { label: string; types: EventType[] }[] = [
|
|||||||
label: "Santé",
|
label: "Santé",
|
||||||
types: ["TEMPERATURE", "MEDICATION", "SYMPTOM"],
|
types: ["TEMPERATURE", "MEDICATION", "SYMPTOM"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Autre",
|
||||||
|
types: ["OTHER"],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
interface Event {
|
interface Event {
|
||||||
@@ -37,12 +41,6 @@ interface Event {
|
|||||||
user: { id: string; name: string };
|
user: { id: string; name: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EventTemplate {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
type: EventType;
|
|
||||||
metadata?: Record<string, unknown> | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBabyAge(birthDate: string): string {
|
function getBabyAge(birthDate: string): string {
|
||||||
const birth = new Date(birthDate);
|
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() {
|
export default function DashboardPage() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
@@ -809,11 +698,6 @@ 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 */}
|
{/* Medication status */}
|
||||||
{medProfiles.length > 0 && (() => {
|
{medProfiles.length > 0 && (() => {
|
||||||
const activeMeds = medProfiles.filter((p) => lastIntakes[p.name]);
|
const activeMeds = medProfiles.filter((p) => lastIntakes[p.name]);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { join } from "path";
|
|||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"];
|
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
|
// Configurable via env so Docker volume can be mounted at /data/uploads
|
||||||
const UPLOAD_DIR = process.env.UPLOAD_DIR ?? join(process.cwd(), "public", "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 (!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 (!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 ext = file.type.split("/")[1].replace("jpeg", "jpg");
|
||||||
const filename = `${randomUUID()}.${ext}`;
|
const filename = `${randomUUID()}.${ext}`;
|
||||||
|
|||||||
@@ -140,6 +140,9 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
|
|||||||
const [milkStocks, setMilkStocks] = useState<MilkStock[]>([]);
|
const [milkStocks, setMilkStocks] = useState<MilkStock[]>([]);
|
||||||
const [selectedStockId, setSelectedStockId] = useState<string | null>(null);
|
const [selectedStockId, setSelectedStockId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Combined diaper toggle
|
||||||
|
const [diaperBoth, setDiaperBoth] = useState(false);
|
||||||
|
|
||||||
// Pump → stock state
|
// Pump → stock state
|
||||||
const [addToStock, setAddToStock] = useState(false);
|
const [addToStock, setAddToStock] = useState(false);
|
||||||
const [pumpStockLocation, setPumpStockLocation] = useState<"room" | "fridge" | "freezer">("fridge");
|
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,
|
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") {
|
if (type === "BOTTLE" && selectedStockId && form.bottleType === "maternal") {
|
||||||
await fetch(`/api/milk/${selectedStockId}`, {
|
await fetch(`/api/milk/${selectedStockId}`, {
|
||||||
@@ -637,6 +655,20 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
|
|||||||
</div>
|
</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 */}
|
{/* Medication — profile picker */}
|
||||||
{type === "MEDICATION" && (
|
{type === "MEDICATION" && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ const QUICK_TYPES: EventType[] = [
|
|||||||
"NIGHT_SLEEP",
|
"NIGHT_SLEEP",
|
||||||
"MEDICATION",
|
"MEDICATION",
|
||||||
"TEMPERATURE",
|
"TEMPERATURE",
|
||||||
|
"BATH",
|
||||||
|
"WALK",
|
||||||
|
"TUMMY_TIME",
|
||||||
|
"SYMPTOM",
|
||||||
|
"OTHER",
|
||||||
];
|
];
|
||||||
|
|
||||||
export function QuickAddFab() {
|
export function QuickAddFab() {
|
||||||
|
|||||||
+12
-1
@@ -11,7 +11,8 @@ export type EventType =
|
|||||||
| "BATH"
|
| "BATH"
|
||||||
| "WALK"
|
| "WALK"
|
||||||
| "TUMMY_TIME"
|
| "TUMMY_TIME"
|
||||||
| "SYMPTOM";
|
| "SYMPTOM"
|
||||||
|
| "OTHER";
|
||||||
|
|
||||||
export const EVENT_CONFIG: Record<
|
export const EVENT_CONFIG: Record<
|
||||||
EventType,
|
EventType,
|
||||||
@@ -164,6 +165,16 @@ export const EVENT_CONFIG: Record<
|
|||||||
hasDuration: false,
|
hasDuration: false,
|
||||||
hasTimer: 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[];
|
export const ALL_EVENT_TYPES = Object.keys(EVENT_CONFIG) as EventType[];
|
||||||
|
|||||||
Reference in New Issue
Block a user