feat: add homeowner from dropdown — any user can add inline

This commit is contained in:
BizzleBot
2026-03-04 21:29:33 +00:00
parent 2f381ff5f9
commit 2f60d7d980
5 changed files with 129 additions and 41 deletions
+2 -1
View File
@@ -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}
+2 -1
View File
@@ -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 && <span className="text-red-500">*</span>}
</label>
<HomeownerSelect
onHomeownerAdded={onHomeownerAdded}
homeowners={homeowners}
value={entry.homeownerId || ''}
onChange={(v) => handleChange('homeownerId', v)}
+88 -39
View File
@@ -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 (
<div className="relative" ref={ref}>
<button
@@ -43,48 +78,62 @@ export default function HomeownerSelect({ homeowners, value, onChange, disabled
</button>
{open && (
<div className="absolute z-50 mt-1 w-full bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 shadow-xl shadow-gray-200/50 dark:shadow-black/40 max-h-64 overflow-hidden">
<div className="p-2 border-b border-gray-100 dark:border-gray-800">
<div className="relative">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
ref={inputRef}
type="text"
value={search}
onChange={(e) => 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"
/>
</div>
</div>
<div className="overflow-y-auto max-h-48 p-1">
{value && (
<div className="absolute z-50 mt-1 w-full bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 shadow-xl shadow-gray-200/50 dark:shadow-black/40 max-h-72 overflow-hidden">
{!adding ? (
<div className="p-2 border-b border-gray-100 dark:border-gray-800">
<button
type="button"
onClick={() => { onChange(''); setOpen(false); setSearch(''); }}
className="w-full text-left px-3 py-2 rounded-lg text-sm text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800"
onClick={() => { setAdding(true); setSearch(''); }}
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-sky-600 dark:text-sky-400 hover:bg-sky-50 dark:hover:bg-sky-500/10 transition-colors"
>
Clear selection
<Plus size={14} />
Add Homeowner
</button>
</div>
) : (
<div className="p-2 border-b border-gray-100 dark:border-gray-800">
<form onSubmit={handleAdd} className="flex items-center gap-2">
<input
ref={newNameRef}
type="text"
value={newName}
onChange={(e) => { 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}
/>
<button type="submit" disabled={saving || !newName.trim()} className="p-2 rounded-lg bg-sky-500 hover:bg-sky-600 text-white disabled:opacity-40 transition-colors" title="Save">
<Check size={14} />
</button>
<button type="button" onClick={() => { setAdding(false); setNewName(''); setError(''); }} className="p-2 rounded-lg bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-500 transition-colors" title="Cancel">
<X size={14} />
</button>
</form>
{error && <p className="mt-1 text-xs text-red-500 px-1">{error}</p>}
</div>
)}
{!adding && (
<div className="p-2 border-b border-gray-100 dark:border-gray-800">
<div className="relative">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input ref={inputRef} type="text" value={search} onChange={(e) => 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" />
</div>
</div>
)}
<div className="overflow-y-auto max-h-48 p-1">
{value && !adding && (
<button type="button" onClick={() => { onChange(''); setOpen(false); setSearch(''); }} className="w-full text-left px-3 py-2 rounded-lg text-sm text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800">Clear selection</button>
)}
{filtered.map((h) => (
<button
key={h.id}
type="button"
onClick={() => { onChange(h.id); setOpen(false); setSearch(''); }}
className={`w-full text-left px-3 py-2.5 rounded-lg text-sm transition-colors ${
h.id === value
? 'bg-sky-50 dark:bg-sky-500/10 text-sky-600 dark:text-sky-400 font-medium'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800'
}`}
>
{!adding && filtered.map((h) => (
<button key={h.id} type="button" onClick={() => { onChange(h.id); setOpen(false); setSearch(''); }}
className={`w-full text-left px-3 py-2.5 rounded-lg text-sm transition-colors ${h.id === value ? 'bg-sky-50 dark:bg-sky-500/10 text-sky-600 dark:text-sky-400 font-medium' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800'}`}>
{h.name}
</button>
))}
{filtered.length === 0 && search && !adding && (
<div className="px-3 py-4 text-center">
<p className="text-sm text-gray-400 mb-2">No match found</p>
</div>
{!adding && filtered.length === 0 && search && (
<div className="px-3 py-4 text-center"><p className="text-sm text-gray-400">No match for "{search}"</p></div>
)}
</div>
</div>
+10
View File
@@ -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}
/>
))}
</div>