Session 2026-05-20: UI fixes, invoice filtering, file browser, request approvals, sub invoice task scope
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { serviceTypes } from '../data/mockData';
|
||||
import FileAttachment from './FileAttachment';
|
||||
import { addDaysToDateOnly, getTodayDateOnlyEST } from '../lib/dates';
|
||||
|
||||
const defaultDeadline = () => addDaysToDateOnly(getTodayDateOnlyEST(), 3);
|
||||
const emptyForm = (companyId = '') => ({
|
||||
companyId,
|
||||
project: '',
|
||||
serviceType: '',
|
||||
title: '',
|
||||
deadline: defaultDeadline(),
|
||||
description: '',
|
||||
isHot: false,
|
||||
requestedBy: '',
|
||||
});
|
||||
|
||||
// Props:
|
||||
// companies [{id, name}] — all selectable companies
|
||||
// currentUser {id, name} — logged-in team member (added to requester list as "You")
|
||||
// showRequester bool — true for team (submitting on behalf of client)
|
||||
// onSubmit async (formData, files, existingProjects) => void — throw to show error
|
||||
// onCancel () => void — optional; shows Cancel button when provided
|
||||
// saving bool
|
||||
// error string
|
||||
// submitLabel string
|
||||
// initialCompanyId string — pre-selected company (client with 1 company)
|
||||
export default function RequestForm({
|
||||
companies = [],
|
||||
currentUser = null,
|
||||
showRequester = false,
|
||||
onSubmit,
|
||||
onCancel = null,
|
||||
saving = false,
|
||||
error = '',
|
||||
submitLabel = 'Submit Request',
|
||||
initialCompanyId = '',
|
||||
}) {
|
||||
const [form, setForm] = useState(() => emptyForm(initialCompanyId));
|
||||
const [files, setFiles] = useState([]);
|
||||
const [existingProjects, setExistingProjects] = useState([]);
|
||||
const [customProjects, setCustomProjects] = useState([]);
|
||||
const [isTypingProject, setIsTypingProject] = useState(false);
|
||||
const [newProjectName, setNewProjectName] = useState('');
|
||||
const [companyUsers, setCompanyUsers] = useState([]);
|
||||
|
||||
const companyId = form.companyId || initialCompanyId;
|
||||
|
||||
useEffect(() => {
|
||||
if (!companyId) { setExistingProjects([]); setCompanyUsers([]); return; }
|
||||
Promise.all([
|
||||
supabase.from('projects').select('id, name').eq('company_id', companyId).order('name'),
|
||||
showRequester
|
||||
? Promise.all([
|
||||
supabase.from('profiles').select('id, name, email').eq('company_id', companyId).eq('role', 'client'),
|
||||
supabase.from('company_members').select('profile:profiles(id, name, email, role)').eq('company_id', companyId),
|
||||
])
|
||||
: Promise.resolve(null),
|
||||
]).then(([projectsRes, usersData]) => {
|
||||
setExistingProjects(projectsRes.data || []);
|
||||
if (usersData) {
|
||||
const [directRes, membersRes] = usersData;
|
||||
const direct = (directRes.data || []);
|
||||
const fromMembers = (membersRes.data || []).map(m => m.profile).filter(p => p?.role === 'client');
|
||||
const seen = new Set();
|
||||
const merged = [...direct, ...fromMembers].filter(u => {
|
||||
if (!u?.id || seen.has(u.id)) return false;
|
||||
seen.add(u.id);
|
||||
return true;
|
||||
}).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||
setCompanyUsers(merged);
|
||||
}
|
||||
});
|
||||
setForm(f => ({ ...f, project: '', requestedBy: '' }));
|
||||
setCustomProjects([]);
|
||||
setIsTypingProject(false);
|
||||
setNewProjectName('');
|
||||
}, [companyId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const set = (field) => (e) => setForm(f => ({ ...f, [field]: e.target.value }));
|
||||
|
||||
const allProjectNames = [
|
||||
...existingProjects.map(p => p.name),
|
||||
...customProjects.filter(name => !existingProjects.some(p => p.name === name)),
|
||||
];
|
||||
|
||||
const handleProjectSelect = (e) => {
|
||||
if (e.target.value === '__new__') {
|
||||
setIsTypingProject(true);
|
||||
setForm(f => ({ ...f, project: '' }));
|
||||
} else {
|
||||
setForm(f => ({ ...f, project: e.target.value }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddProject = () => {
|
||||
const name = newProjectName.trim();
|
||||
if (!name) return;
|
||||
if (!customProjects.includes(name) && !existingProjects.some(p => p.name === name)) {
|
||||
setCustomProjects(prev => [...prev, name]);
|
||||
}
|
||||
setForm(f => ({ ...f, project: name }));
|
||||
setIsTypingProject(false);
|
||||
setNewProjectName('');
|
||||
};
|
||||
|
||||
const requesterOptions = showRequester ? [
|
||||
...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)`, email: currentUser.email || '' }] : []),
|
||||
...companyUsers.filter(u => u.id !== currentUser?.id),
|
||||
] : [];
|
||||
|
||||
const showCompanySelect = companies.length > 1 || showRequester;
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
const requester = requesterOptions.find(u => u.id === form.requestedBy);
|
||||
const requestedByName = requester ? requester.name.replace(' (You)', '') : '';
|
||||
onSubmit({ ...form, companyId, requestedByName }, files, existingProjects);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
{showCompanySelect && (
|
||||
<div className="form-group">
|
||||
<label>Company *</label>
|
||||
<select
|
||||
value={form.companyId}
|
||||
onChange={e => setForm(f => ({ ...f, companyId: e.target.value }))}
|
||||
required
|
||||
>
|
||||
<option value="">Select company...</option>
|
||||
{companies.map(co => <option key={co.id} value={co.id}>{co.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label>Project *</label>
|
||||
{isTypingProject ? (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter project name..."
|
||||
value={newProjectName}
|
||||
onChange={e => setNewProjectName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleAddProject(); } }}
|
||||
autoFocus
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={handleAddProject} disabled={!newProjectName.trim()}>Add</button>
|
||||
<button type="button" className="btn btn-outline btn-sm" onClick={() => { setIsTypingProject(false); setNewProjectName(''); }}>Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<select value={form.project} onChange={handleProjectSelect} required disabled={showCompanySelect && !companyId}>
|
||||
<option value="">{showCompanySelect && !companyId ? 'Select company first' : 'Select a project...'}</option>
|
||||
{allProjectNames.map(name => <option key={name} value={name}>{name}</option>)}
|
||||
{(!showCompanySelect || companyId) && <option value="__new__">+ Create new project...</option>}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Service Type *</label>
|
||||
<select value={form.serviceType} onChange={set('serviceType')} required>
|
||||
<option value="">Select service...</option>
|
||||
{serviceTypes.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Desired Deadline</label>
|
||||
<input type="date" value={form.deadline} onChange={set('deadline')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group" style={{ marginTop: -4 }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 10, fontWeight: 500, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={form.isHot} onChange={e => setForm(f => ({ ...f, isHot: e.target.checked }))} />
|
||||
<span>Mark as Hot</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{showRequester && (
|
||||
<div className="form-group">
|
||||
<label>Requested By *</label>
|
||||
<select value={form.requestedBy} onChange={set('requestedBy')} disabled={!companyId} required>
|
||||
<option value="">{companyId ? 'Select requester...' : 'Select company first'}</option>
|
||||
{requesterOptions.map(user => (
|
||||
<option key={user.id} value={user.id}>{user.name}{user.email ? ` (${user.email})` : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label>Request Title *</label>
|
||||
<input type="text" placeholder="e.g. Street Name" value={form.title} onChange={set('title')} required />
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Description *</label>
|
||||
<textarea placeholder="Notes on the request..." value={form.description} onChange={set('description')} style={{ minHeight: 100 }} required />
|
||||
</div>
|
||||
|
||||
<FileAttachment files={files} onChange={setFiles} />
|
||||
|
||||
{error && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>⚠ {error}</div>}
|
||||
|
||||
<div className="action-buttons">
|
||||
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||
{saving ? 'Submitting...' : submitLabel}
|
||||
</button>
|
||||
{onCancel && <button type="button" className="btn btn-outline" onClick={onCancel}>Cancel</button>}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user