added ability to add caretakers

This commit is contained in:
John Overton
2025-03-09 15:49:29 -05:00
parent 890df37e1b
commit e970736dac
7 changed files with 616 additions and 6 deletions

233
app/api/caretaker/route.ts Normal file
View File

@@ -0,0 +1,233 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '../db';
import { ApiResponse, CaretakerCreate, CaretakerUpdate, CaretakerResponse } from '../types';
import { formatLocalTime } from '../utils/timezone';
export async function POST(req: NextRequest) {
try {
const body: CaretakerCreate = await req.json();
// Check if loginId is already in use
const existingCaretaker = await prisma.caretaker.findFirst({
where: {
// Using type assertion to handle new field that TypeScript doesn't know about yet
loginId: body.loginId,
deletedAt: null,
} as any,
});
if (existingCaretaker) {
return NextResponse.json<ApiResponse<CaretakerResponse>>(
{
success: false,
error: 'Login ID is already in use. Please choose a different one.',
},
{ status: 400 }
);
}
const caretaker = await prisma.caretaker.create({
data: body,
});
// Format response with local timezone
const response: CaretakerResponse = {
...caretaker,
createdAt: await formatLocalTime(caretaker.createdAt),
updatedAt: await formatLocalTime(caretaker.updatedAt),
deletedAt: caretaker.deletedAt ? await formatLocalTime(caretaker.deletedAt) : null,
};
return NextResponse.json<ApiResponse<CaretakerResponse>>({
success: true,
data: response,
});
} catch (error) {
console.error('Error creating caretaker:', error);
return NextResponse.json<ApiResponse<CaretakerResponse>>(
{
success: false,
error: 'Failed to create caretaker',
},
{ status: 500 }
);
}
}
export async function PUT(req: NextRequest) {
try {
const body: CaretakerUpdate = await req.json();
const { id, ...updateData } = body;
const existingCaretaker = await prisma.caretaker.findUnique({
where: { id },
});
if (!existingCaretaker) {
return NextResponse.json<ApiResponse<CaretakerResponse>>(
{
success: false,
error: 'Caretaker not found',
},
{ status: 404 }
);
}
// If loginId is being updated, check if it's already in use by another caretaker
if (updateData.loginId) {
const duplicateLoginId = await prisma.caretaker.findFirst({
where: {
// Using type assertion to handle new field that TypeScript doesn't know about yet
loginId: updateData.loginId,
id: { not: id },
deletedAt: null,
} as any,
});
if (duplicateLoginId) {
return NextResponse.json<ApiResponse<CaretakerResponse>>(
{
success: false,
error: 'Login ID is already in use. Please choose a different one.',
},
{ status: 400 }
);
}
}
const caretaker = await prisma.caretaker.update({
where: { id },
data: updateData,
});
// Format response with local timezone
const response: CaretakerResponse = {
...caretaker,
createdAt: await formatLocalTime(caretaker.createdAt),
updatedAt: await formatLocalTime(caretaker.updatedAt),
deletedAt: caretaker.deletedAt ? await formatLocalTime(caretaker.deletedAt) : null,
};
return NextResponse.json<ApiResponse<CaretakerResponse>>({
success: true,
data: response,
});
} catch (error) {
console.error('Error updating caretaker:', error);
return NextResponse.json<ApiResponse<CaretakerResponse>>(
{
success: false,
error: 'Failed to update caretaker',
},
{ status: 500 }
);
}
}
export async function DELETE(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const id = searchParams.get('id');
if (!id) {
return NextResponse.json<ApiResponse>(
{
success: false,
error: 'Caretaker ID is required',
},
{ status: 400 }
);
}
// Soft delete by setting deletedAt
await prisma.caretaker.update({
where: { id },
data: { deletedAt: new Date() },
});
return NextResponse.json<ApiResponse>({
success: true,
});
} catch (error) {
console.error('Error deleting caretaker:', error);
return NextResponse.json<ApiResponse>(
{
success: false,
error: 'Failed to delete caretaker',
},
{ status: 500 }
);
}
}
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const id = searchParams.get('id');
if (id) {
const caretaker = await prisma.caretaker.findUnique({
where: {
id,
deletedAt: null,
},
});
if (!caretaker) {
return NextResponse.json<ApiResponse<CaretakerResponse>>(
{
success: false,
error: 'Caretaker not found',
},
{ status: 404 }
);
}
// Format response with local timezone
const response: CaretakerResponse = {
...caretaker,
createdAt: await formatLocalTime(caretaker.createdAt),
updatedAt: await formatLocalTime(caretaker.updatedAt),
deletedAt: caretaker.deletedAt ? await formatLocalTime(caretaker.deletedAt) : null,
};
return NextResponse.json<ApiResponse<CaretakerResponse>>({
success: true,
data: response,
});
}
const caretakers = await prisma.caretaker.findMany({
where: {
deletedAt: null,
},
orderBy: {
name: 'asc',
},
});
// Format response with local timezone
const response: CaretakerResponse[] = await Promise.all(
caretakers.map(async (caretaker) => ({
...caretaker,
createdAt: await formatLocalTime(caretaker.createdAt),
updatedAt: await formatLocalTime(caretaker.updatedAt),
deletedAt: caretaker.deletedAt ? await formatLocalTime(caretaker.deletedAt) : null,
}))
);
return NextResponse.json<ApiResponse<CaretakerResponse[]>>({
success: true,
data: response,
});
} catch (error) {
console.error('Error fetching caretakers:', error);
return NextResponse.json<ApiResponse<CaretakerResponse[]>>(
{
success: false,
error: 'Failed to fetch caretakers',
},
{ status: 500 }
);
}
}

View File

@@ -1,4 +1,4 @@
import { Baby, SleepLog, FeedLog, DiaperLog, MoodLog, Note, Settings as PrismaSettings, Gender, SleepType, SleepQuality, FeedType, BreastSide, DiaperType, Mood } from '@prisma/client';
import { Baby, SleepLog, FeedLog, DiaperLog, MoodLog, Note, Caretaker, Settings as PrismaSettings, Gender, SleepType, SleepQuality, FeedType, BreastSide, DiaperType, Mood } from '@prisma/client';
// Settings types
export type Settings = PrismaSettings;
@@ -116,3 +116,21 @@ export interface NoteCreate {
content: string;
category?: string;
}
// Caretaker types
export type CaretakerResponse = Omit<Caretaker, 'createdAt' | 'updatedAt' | 'deletedAt'> & {
createdAt: string;
updatedAt: string;
deletedAt: string | null;
};
export interface CaretakerCreate {
loginId: string;
name: string;
type?: string;
securityPin: string;
}
export interface CaretakerUpdate extends Partial<CaretakerCreate> {
id: string;
}

View File

@@ -0,0 +1,25 @@
/*
Warnings:
- Added the required column `loginId` to the `Caretaker` table without a default value. This is not possible if the table is not empty.
*/
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Caretaker" (
"id" TEXT NOT NULL PRIMARY KEY,
"loginId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" TEXT,
"securityPin" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"deletedAt" DATETIME
);
INSERT INTO "new_Caretaker" ("loginId", "createdAt", "deletedAt", "id", "name", "securityPin", "type", "updatedAt") SELECT '01', "createdAt", "deletedAt", "id", "name", "securityPin", "type", "updatedAt" FROM "Caretaker";
DROP TABLE "Caretaker";
ALTER TABLE "new_Caretaker" RENAME TO "Caretaker";
CREATE INDEX "Caretaker_deletedAt_idx" ON "Caretaker"("deletedAt");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -77,6 +77,7 @@ model Baby {
model Caretaker {
id String @id @default(uuid())
loginId String // Two-digit login identifier for quick authentication
name String
type String? // parent, daycare, nanny, grandparent, uncle, etc.
securityPin String

View File

@@ -1,7 +1,7 @@
'use client';
import React, { useEffect, useState } from 'react';
import { Baby, Unit } from '@prisma/client';
import { Baby, Unit, Caretaker } from '@prisma/client';
import { Settings } from '@/app/api/types';
import { Settings as Plus, Edit, Download, Upload } from 'lucide-react';
import { Button } from '@/src/components/ui/button';
@@ -21,6 +21,7 @@ import {
} from '@/src/components/ui/form-page';
import BabyModal from '@/src/components/modals/BabyModal';
import ChangePinModal from '@/src/components/modals/ChangePinModal';
import CaretakerModal from '@/src/components/modals/CaretakerModal';
interface SettingsFormProps {
isOpen: boolean;
@@ -39,10 +40,13 @@ export default function SettingsForm({
}: SettingsFormProps) {
const [settings, setSettings] = useState<Settings | null>(null);
const [babies, setBabies] = useState<Baby[]>([]);
const [caretakers, setCaretakers] = useState<Caretaker[]>([]);
const [loading, setLoading] = useState(true);
const [showBabyModal, setShowBabyModal] = useState(false);
const [showCaretakerModal, setShowCaretakerModal] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [selectedBaby, setSelectedBaby] = useState<Baby | null>(null);
const [selectedCaretaker, setSelectedCaretaker] = useState<Caretaker | null>(null);
const [localSelectedBabyId, setLocalSelectedBabyId] = useState<string | undefined>(selectedBabyId);
const [showChangePinModal, setShowChangePinModal] = useState(false);
const [isRestoring, setIsRestoring] = useState(false);
@@ -56,10 +60,11 @@ export default function SettingsForm({
const fetchData = async () => {
try {
setLoading(true);
const [settingsResponse, babiesResponse, unitsResponse] = await Promise.all([
const [settingsResponse, babiesResponse, unitsResponse, caretakersResponse] = await Promise.all([
fetch('/api/settings'),
fetch('/api/baby'),
fetch('/api/units')
fetch('/api/units'),
fetch('/api/caretaker')
]);
if (settingsResponse.ok) {
@@ -76,6 +81,11 @@ export default function SettingsForm({
const unitsData = await unitsResponse.json();
setUnits(unitsData.data);
}
if (caretakersResponse.ok) {
const caretakersData = await caretakersResponse.json();
setCaretakers(caretakersData.data);
}
} catch (error) {
console.error('Error fetching data:', error);
} finally {
@@ -115,6 +125,11 @@ export default function SettingsForm({
onBabyStatusChange?.(); // Refresh parent's babies list
};
const handleCaretakerModalClose = async () => {
setShowCaretakerModal(false);
await fetchData(); // Refresh local caretakers list
};
const handleBackup = async () => {
try {
const response = await fetch('/api/database');
@@ -291,6 +306,53 @@ export default function SettingsForm({
</div>
</div>
<div className="border-t border-slate-200 pt-6">
<h3 className="form-label mb-4">Manage Caretakers</h3>
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2 w-full">
<div className="flex-1 min-w-[200px]">
<Select
value={selectedCaretaker?.id || ''}
onValueChange={(caretakerId) => {
const caretaker = caretakers.find(c => c.id === caretakerId);
setSelectedCaretaker(caretaker || null);
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a caretaker" />
</SelectTrigger>
<SelectContent>
{caretakers.map((caretaker) => (
<SelectItem key={caretaker.id} value={caretaker.id}>
{caretaker.name} {caretaker.type ? `(${caretaker.type})` : ''}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
variant="outline"
disabled={!selectedCaretaker}
onClick={() => {
setIsEditing(true);
setShowCaretakerModal(true);
}}
>
<Edit className="h-4 w-3 mr-2" />
Edit
</Button>
<Button variant="outline" onClick={() => {
setIsEditing(false);
setSelectedCaretaker(null);
setShowCaretakerModal(true);
}}>
<Plus className="h-4 w-3 mr-2" />
Add
</Button>
</div>
</div>
</div>
<div className="border-t border-slate-200 pt-6">
<h3 className="form-label mb-4">Default Units</h3>
<div className="space-y-4">
@@ -432,6 +494,13 @@ export default function SettingsForm({
baby={selectedBaby}
/>
<CaretakerModal
open={showCaretakerModal}
onClose={handleCaretakerModalClose}
isEditing={isEditing}
caretaker={selectedCaretaker}
/>
<ChangePinModal
open={showChangePinModal}
onClose={() => setShowChangePinModal(false)}

View File

@@ -0,0 +1,264 @@
import { Caretaker as PrismaCaretaker } from '@prisma/client';
// Extended type to include the new loginId field
interface Caretaker extends PrismaCaretaker {
loginId: string;
}
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/src/components/ui/dialog';
import { Button } from '@/src/components/ui/button';
import { Input } from '@/src/components/ui/input';
import { useState, useEffect } from 'react';
interface CaretakerModalProps {
open: boolean;
onClose: () => void;
isEditing: boolean;
caretaker: (PrismaCaretaker & { loginId?: string }) | null;
}
const defaultFormData = {
loginId: '',
name: '',
type: '',
securityPin: '',
};
export default function CaretakerModal({
open,
onClose,
isEditing,
caretaker,
}: CaretakerModalProps) {
const [formData, setFormData] = useState(defaultFormData);
const [confirmPin, setConfirmPin] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState('');
// Reset form when modal opens/closes or caretaker changes
useEffect(() => {
if (caretaker && open) {
setFormData({
loginId: caretaker.loginId || '',
name: caretaker.name,
type: caretaker.type || '',
securityPin: caretaker.securityPin,
});
setConfirmPin(caretaker.securityPin);
} else if (!open) {
setFormData(defaultFormData);
setConfirmPin('');
setError('');
}
}, [caretaker, open]);
const validatePIN = () => {
if (formData.securityPin.length < 6) {
setError('PIN must be at least 6 digits');
return false;
}
if (formData.securityPin.length > 10) {
setError('PIN cannot be longer than 10 digits');
return false;
}
if (formData.securityPin !== confirmPin) {
setError('PINs do not match');
return false;
}
return true;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (isSubmitting) return;
// Validate form
if (!formData.loginId.trim()) {
setError('Login ID is required');
return;
}
if (formData.loginId.length !== 2) {
setError('Login ID must be exactly 2 characters');
return;
}
if (!formData.name.trim()) {
setError('Name is required');
return;
}
if (!validatePIN()) {
return;
}
try {
setIsSubmitting(true);
setError('');
const response = await fetch('/api/caretaker', {
method: isEditing ? 'PUT' : 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
...formData,
id: caretaker?.id,
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to save caretaker');
}
onClose();
} catch (error) {
console.error('Error saving caretaker:', error);
setError(error instanceof Error ? error.message : 'Failed to save caretaker');
} finally {
setIsSubmitting(false);
}
};
const handleClose = () => {
setFormData(defaultFormData);
setConfirmPin('');
setError('');
onClose();
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="dialog-content !p-4 sm:!p-6">
<DialogHeader className="dialog-header">
<DialogTitle className="dialog-title">
{isEditing ? 'Edit Caretaker' : 'Add New Caretaker'}
</DialogTitle>
<DialogDescription className="dialog-description">
{isEditing
? "Update caretaker information"
: "Enter caretaker information to add them to the system"
}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="form-label">Login ID</label>
<Input
value={formData.loginId}
onChange={(e) => {
const value = e.target.value;
// Allow any input up to 2 characters
setFormData({ ...formData, loginId: value });
}}
className="w-full"
placeholder="Enter 2-digit login ID"
maxLength={2}
required
autoComplete="off"
/>
<p className="text-xs text-gray-500 mt-1">
Login ID must be exactly 2 digits or characters (currently: {formData.loginId.length}/2)
</p>
</div>
<div>
<label className="form-label">Name</label>
<Input
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
className="w-full"
placeholder="Enter caretaker name"
required
/>
</div>
<div>
<label className="form-label">Type (Optional)</label>
<Input
value={formData.type}
onChange={(e) =>
setFormData({ ...formData, type: e.target.value })
}
className="w-full"
placeholder="Parent, Grandparent, Nanny, etc."
/>
</div>
<div>
<label className="form-label">Security PIN</label>
<Input
type="password"
value={formData.securityPin}
onChange={(e) => {
const value = e.target.value.replace(/\D/g, '');
if (value.length <= 10) {
setFormData({ ...formData, securityPin: value });
}
}}
className="w-full"
placeholder="Enter 6-10 digit PIN"
minLength={6}
maxLength={10}
pattern="\d*"
required
/>
<p className="text-xs text-gray-500 mt-1">PIN must be between 6 and 10 digits</p>
</div>
<div>
<label className="form-label">Confirm PIN</label>
<Input
type="password"
value={confirmPin}
onChange={(e) => {
const value = e.target.value.replace(/\D/g, '');
if (value.length <= 10) {
setConfirmPin(value);
}
}}
className="w-full"
placeholder="Confirm PIN"
minLength={6}
maxLength={10}
pattern="\d*"
required
/>
</div>
{error && (
<div className="text-sm text-red-500 font-medium">{error}</div>
)}
<div className="grid grid-cols-2 sm:flex sm:justify-end gap-3 mt-6">
<Button
type="button"
variant="outline"
onClick={handleClose}
className="hover:bg-slate-50"
disabled={isSubmitting}
>
Cancel
</Button>
<Button
type="submit"
className="bg-gradient-to-r from-teal-600 to-emerald-600 text-white hover:from-teal-700 hover:to-emerald-700"
disabled={isSubmitting}
>
{isSubmitting
? 'Saving...'
: isEditing
? 'Save Changes'
: 'Add Caretaker'
}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,6 +1,6 @@
export const dialogStyles = {
overlay: "fixed inset-0 z-50 bg-black/40 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
content: "fixed left-[50%] top-[50%] z-50 grid w-full max-w-[400px] sm:max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 bg-white/95 backdrop-blur-sm px-4 py-6 sm:p-6 shadow-xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-2xl border border-slate-200",
overlay: "fixed inset-0 z-[100] bg-black/40 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
content: "fixed left-[50%] top-[50%] z-[101] grid w-full max-w-[400px] sm:max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 bg-white/95 backdrop-blur-sm px-4 py-6 sm:p-6 shadow-xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-2xl border border-slate-200",
closeButton: "absolute right-4 top-4 rounded-full p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100/80 transition-colors",
header: "flex flex-col space-y-1.5 text-center sm:text-left",
footer: "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",