From 2f60d7d9803cef6a57cf039ea94e4475a2d5b9f1 Mon Sep 17 00:00:00 2001 From: BizzleBot Date: Wed, 4 Mar 2026 21:29:33 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20add=20homeowner=20from=20dropdown=20?= =?UTF-8?q?=E2=80=94=20any=20user=20can=20add=20inline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/routes/homeowners.js | 27 +++++ frontend/src/components/DayCard.jsx | 3 +- frontend/src/components/EntryForm.jsx | 3 +- frontend/src/components/HomeownerSelect.jsx | 127 ++++++++++++++------ frontend/src/pages/Timesheet.jsx | 10 ++ 5 files changed, 129 insertions(+), 41 deletions(-) diff --git a/backend/src/routes/homeowners.js b/backend/src/routes/homeowners.js index d908422..1c22cca 100644 --- a/backend/src/routes/homeowners.js +++ b/backend/src/routes/homeowners.js @@ -21,3 +21,30 @@ router.get('/', async (req, res) => { }); module.exports = router; + +// POST /api/homeowners — any authenticated user can add a homeowner +router.post("/", async (req, res) => { + try { + const { name } = req.body; + if (!name || typeof name !== "string" || name.trim().length < 2) { + return res.status(400).json({ error: "Name must be at least 2 characters" }); + } + const trimmed = name.trim(); + // Check for duplicate + const existing = await req.prisma.homeowner.findFirst({ + where: { name: { equals: trimmed, mode: "insensitive" } }, + }); + if (existing) { + // Return existing rather than error — convenient for the UI + return res.json({ homeowner: existing, existing: true }); + } + const homeowner = await req.prisma.homeowner.create({ + data: { name: trimmed }, + select: { id: true, name: true }, + }); + res.status(201).json({ homeowner }); + } catch (err) { + console.error("Create homeowner error:", err); + res.status(500).json({ error: "Failed to create homeowner" }); + } +}); diff --git a/frontend/src/components/DayCard.jsx b/frontend/src/components/DayCard.jsx index 149f841..a2d033c 100644 --- a/frontend/src/components/DayCard.jsx +++ b/frontend/src/components/DayCard.jsx @@ -5,7 +5,7 @@ import EntryForm from './EntryForm'; const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; const SHORT_DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; -export default function DayCard({ date, entries, homeowners, onEntryChange, onAddEntry, onDeleteEntry, disabled, defaultExpanded }) { +export default function DayCard({ date, entries, homeowners, onEntryChange, onAddEntry, onDeleteEntry, disabled, defaultExpanded, onHomeownerAdded }) { const [expanded, setExpanded] = useState(defaultExpanded); // Parse YYYY-MM-DD as local date (avoid UTC midnight timezone shift) const [y, m, dy] = date.split('-').map(Number); @@ -68,6 +68,7 @@ export default function DayCard({ date, entries, homeowners, onEntryChange, onAd key={entry.id} entry={entry} homeowners={homeowners} + onHomeownerAdded={onHomeownerAdded} onChange={onEntryChange} onDelete={onDeleteEntry} canDelete={(entries || []).length > 1} diff --git a/frontend/src/components/EntryForm.jsx b/frontend/src/components/EntryForm.jsx index bbd89ad..4e75066 100644 --- a/frontend/src/components/EntryForm.jsx +++ b/frontend/src/components/EntryForm.jsx @@ -1,7 +1,7 @@ import { Trash2 } from 'lucide-react'; import HomeownerSelect from './HomeownerSelect'; -export default function EntryForm({ entry, homeowners, onChange, onDelete, canDelete, disabled }) { +export default function EntryForm({ entry, homeowners, onChange, onDelete, canDelete, disabled, onHomeownerAdded }) { function handleChange(field, value) { onChange(entry.id, { ...entry, [field]: value }); } @@ -22,6 +22,7 @@ export default function EntryForm({ entry, homeowners, onChange, onDelete, canDe Homeowner {isPartial && !entry.homeownerId && *} handleChange('homeownerId', v)} diff --git a/frontend/src/components/HomeownerSelect.jsx b/frontend/src/components/HomeownerSelect.jsx index 592efb2..4df3739 100644 --- a/frontend/src/components/HomeownerSelect.jsx +++ b/frontend/src/components/HomeownerSelect.jsx @@ -1,24 +1,38 @@ import { useState, useRef, useEffect } from 'react'; -import { ChevronDown, Plus, Search } from 'lucide-react'; +import { ChevronDown, Plus, Search, Check, X } from 'lucide-react'; +import api from '../api/client'; -export default function HomeownerSelect({ homeowners, value, onChange, disabled }) { +export default function HomeownerSelect({ homeowners, value, onChange, onHomeownerAdded, disabled }) { const [open, setOpen] = useState(false); const [search, setSearch] = useState(''); const [adding, setAdding] = useState(false); + const [newName, setNewName] = useState(''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); const ref = useRef(null); const inputRef = useRef(null); + const newNameRef = useRef(null); useEffect(() => { function handleClick(e) { - if (ref.current && !ref.current.contains(e.target)) setOpen(false); + if (ref.current && !ref.current.contains(e.target)) { + setOpen(false); + setAdding(false); + setNewName(''); + setError(''); + } } document.addEventListener('mousedown', handleClick); return () => document.removeEventListener('mousedown', handleClick); }, []); useEffect(() => { - if (open && inputRef.current) inputRef.current.focus(); - }, [open]); + if (open && !adding && inputRef.current) inputRef.current.focus(); + }, [open, adding]); + + useEffect(() => { + if (adding && newNameRef.current) newNameRef.current.focus(); + }, [adding]); const filtered = homeowners.filter((h) => h.name.toLowerCase().includes(search.toLowerCase()) @@ -26,6 +40,27 @@ export default function HomeownerSelect({ homeowners, value, onChange, disabled const selectedLabel = homeowners.find((h) => h.id === value)?.name || ''; + async function handleAdd(e) { + e.preventDefault(); + if (!newName.trim()) return; + setSaving(true); + setError(''); + try { + const res = await api.post('/homeowners', { name: newName.trim() }); + const ho = res.data.homeowner; + if (onHomeownerAdded) onHomeownerAdded(ho); + onChange(ho.id); + setOpen(false); + setAdding(false); + setNewName(''); + setSearch(''); + } catch (err) { + setError(err.response?.data?.error || 'Failed to add homeowner'); + } finally { + setSaving(false); + } + } + return (
+
+ ) : ( +
+
+ { setNewName(e.target.value); setError(''); }} + placeholder="Homeowner name..." + className="flex-1 px-3 py-2 rounded-lg bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-sky-500/40" + disabled={saving} + /> + + +
+ {error &&

{error}

} +
+ )} + + {!adding && ( +
+
+ + setSearch(e.target.value)} placeholder="Search..." className="w-full pl-8 pr-3 py-2 rounded-lg bg-gray-50 dark:bg-gray-800 border-none text-sm text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none" /> +
+
+ )} + +
+ {value && !adding && ( + )} - {filtered.map((h) => ( - ))} - {filtered.length === 0 && search && !adding && ( -
-

No match found

-
+ {!adding && filtered.length === 0 && search && ( +

No match for "{search}"

)}
diff --git a/frontend/src/pages/Timesheet.jsx b/frontend/src/pages/Timesheet.jsx index b26efbd..6eb11eb 100644 --- a/frontend/src/pages/Timesheet.jsx +++ b/frontend/src/pages/Timesheet.jsx @@ -36,6 +36,15 @@ export default function Timesheet() { const [selectedDate, setSelectedDate] = useState(new Date()); const [entries, setEntries] = useState({}); const [homeowners, setHomeowners] = useState([]); + + // Handler when a new homeowner is added via the dropdown + function handleHomeownerAdded(newHomeowner) { + setHomeowners((prev) => { + if (prev.find((h) => h.id === newHomeowner.id)) return prev; + return [...prev, newHomeowner].sort((a, b) => a.name.localeCompare(b.name)); + }); + } + const [timesheet, setTimesheet] = useState(null); const [saveStatus, setSaveStatus] = useState('saved'); const [loading, setLoading] = useState(true); @@ -310,6 +319,7 @@ export default function Timesheet() { onDeleteEntry={handleDeleteEntry} disabled={isLocked} defaultExpanded={date === today} + onHomeownerAdded={handleHomeownerAdded} /> ))}