feat: add homeowner from dropdown — any user can add inline
This commit is contained in:
@@ -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" });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user