70ad8d0cef
- ProjectDetail: full redesign — icon meta row, completion progress bar, main contact card, activity feed, subcontractor management modal with multi-select - ProjectDetail: task table matches Tasks.jsx (R#, assigned avatar, priority, type, deadline, status) with status tabs + counts - Realtime: useRefetchOnFocus + useRealtimeSubscription hooks wired to Tasks, TaskDetail, ProjectDetail, TeamDashboard - AuthContext: live profile updates via Supabase realtime channel - Tasks/ProjectDetail: assignee avatar join added for all roles (client, external, team) - RLS: allow all authenticated users to read profiles (fixes avatar display across roles) - RequestForm: lockedFields prop for pre-filled read-only company/project fields Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
284 lines
12 KiB
React
284 lines
12 KiB
React
import { useState, useEffect, useRef } 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 modalLabelStyle = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 6 };
|
||
const modalInputStyle = { fontSize: 13, padding: '0 5px', textAlign: 'left', lineHeight: 1 };
|
||
const modalTextAreaStyle = { ...modalInputStyle, minHeight: 100, padding: '8px 5px', lineHeight: 1.4 };
|
||
const emptyForm = (companyId = '') => ({
|
||
companyId,
|
||
project: '',
|
||
serviceType: '',
|
||
signFamily: '',
|
||
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 = '',
|
||
initialValues = null,
|
||
lockedFields = [],
|
||
}) {
|
||
const [form, setForm] = useState(() => ({ ...emptyForm(initialCompanyId), ...(initialValues || {}) }));
|
||
const prevCompanyIdRef = useRef(initialValues?.companyId || initialCompanyId || null);
|
||
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 [signFamilies, setSignFamilies] = useState([]);
|
||
const [signCount, setSignCount] = useState('');
|
||
const [signs, setSigns] = useState([]);
|
||
|
||
const isBrandBook = form.serviceType === 'Brand Book';
|
||
|
||
useEffect(() => {
|
||
supabase.from('sign_families').select('name').order('sort_order').then(({ data }) => setSignFamilies((data || []).map(r => r.name)));
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!isBrandBook) { setSignCount(''); setSigns([]); }
|
||
}, [isBrandBook]);
|
||
|
||
useEffect(() => {
|
||
const n = Math.max(0, parseInt(signCount) || 0);
|
||
setSigns(prev => {
|
||
if (n < prev.length) return prev.slice(0, n);
|
||
if (n > prev.length) {
|
||
const extra = Array.from({ length: n - prev.length }, () => ({ id: crypto.randomUUID(), signName: '', signFamily: '' }));
|
||
return [...prev, ...extra];
|
||
}
|
||
return prev;
|
||
});
|
||
}, [signCount]);
|
||
|
||
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);
|
||
}
|
||
});
|
||
if (companyId !== prevCompanyIdRef.current) {
|
||
setForm(f => ({ ...f, project: '', requestedBy: '' }));
|
||
setCustomProjects([]);
|
||
setIsTypingProject(false);
|
||
setNewProjectName('');
|
||
}
|
||
prevCompanyIdRef.current = companyId;
|
||
}, [companyId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
const set = (field) => (e) => setForm(f => ({ ...f, [field]: e.target.value }));
|
||
|
||
const setSign = (id, field, value) => setSigns(prev => prev.map(s => s.id === id ? { ...s, [field]: value } : s));
|
||
|
||
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 showCompanySelect = companies.length > 1 || showRequester;
|
||
|
||
const handleSubmit = (e) => {
|
||
e.preventDefault();
|
||
const requestedByName = currentUser?.name || '';
|
||
onSubmit(
|
||
{
|
||
...form,
|
||
companyId,
|
||
requestedBy: currentUser?.id || '',
|
||
requestedByName,
|
||
signCount: isBrandBook ? (parseInt(signCount) || null) : null,
|
||
signs: isBrandBook ? signs : [],
|
||
},
|
||
files,
|
||
existingProjects
|
||
);
|
||
};
|
||
|
||
return (
|
||
<form onSubmit={handleSubmit}>
|
||
{/* Row 1: Company + Project */}
|
||
<div className="grid-2">
|
||
{lockedFields.includes('company') ? (
|
||
<div className="form-group">
|
||
<label style={modalLabelStyle}>Company</label>
|
||
<input value={companies.find(c => c.id === companyId)?.name || companyId} disabled style={{ ...modalInputStyle, opacity: 0.5, cursor: 'not-allowed' }} />
|
||
</div>
|
||
) : showCompanySelect ? (
|
||
<div className="form-group">
|
||
<label style={modalLabelStyle}>Company *</label>
|
||
<select value={form.companyId} onChange={e => setForm(f => ({ ...f, companyId: e.target.value }))} required style={modalInputStyle}>
|
||
<option value="">Select company...</option>
|
||
{companies.map(co => <option key={co.id} value={co.id}>{co.name}</option>)}
|
||
</select>
|
||
</div>
|
||
) : <div />}
|
||
<div className="form-group">
|
||
<label style={modalLabelStyle}>Project {!lockedFields.includes('project') && '*'}</label>
|
||
{lockedFields.includes('project') ? (
|
||
<input value={form.project} disabled style={{ ...modalInputStyle, opacity: 0.5, cursor: 'not-allowed' }} />
|
||
) : 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={{ ...modalInputStyle, flex: 1 }} />
|
||
<button type="button" className="btn btn-outline" onClick={handleAddProject} disabled={!newProjectName.trim()}>Add</button>
|
||
<button type="button" className="btn btn-outline" onClick={() => { setIsTypingProject(false); setNewProjectName(''); }}>Cancel</button>
|
||
</div>
|
||
) : (
|
||
<select value={form.project} onChange={handleProjectSelect} required disabled={showCompanySelect && !companyId} style={modalInputStyle}>
|
||
<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>
|
||
|
||
{/* Row 2: Service Type + Desired Deadline */}
|
||
<div className="grid-2">
|
||
<div className="form-group">
|
||
<label style={modalLabelStyle}>Service Type *</label>
|
||
<select value={form.serviceType} onChange={set('serviceType')} required style={modalInputStyle}>
|
||
<option value="">Select service...</option>
|
||
{serviceTypes.map(s => <option key={s} value={s}>{s}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="form-group">
|
||
<label style={modalLabelStyle}>Desired Deadline</label>
|
||
<input type="date" value={form.deadline} onChange={set('deadline')} style={modalInputStyle} />
|
||
</div>
|
||
</div>
|
||
|
||
{/* Row 3: Title/Location + Mark as Hot inline */}
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 12, alignItems: 'end', marginBottom: 16 }}>
|
||
<div className="form-group" style={{ marginBottom: 0 }}>
|
||
<label style={modalLabelStyle}>Title / Location *</label>
|
||
<input type="text" placeholder="e.g. City, State or Site Name" value={form.title} onChange={set('title')} required style={modalInputStyle} />
|
||
</div>
|
||
<label style={{ ...modalLabelStyle, display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', marginBottom: 4, whiteSpace: 'nowrap' }}>
|
||
<input type="checkbox" checked={form.isHot} onChange={e => setForm(f => ({ ...f, isHot: e.target.checked }))} />
|
||
<span>Mark as Hot</span>
|
||
</label>
|
||
</div>
|
||
|
||
{isBrandBook && (
|
||
<div className="form-group">
|
||
<label style={modalLabelStyle}>Sign Count *</label>
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
max="50"
|
||
placeholder="How many signs?"
|
||
value={signCount}
|
||
onChange={e => setSignCount(e.target.value)}
|
||
required
|
||
style={{ ...modalInputStyle, width: '100%' }}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{isBrandBook && signs.length > 0 && (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
|
||
{signs.map((sign, i) => (
|
||
<div key={sign.id} style={{ border: '1px solid var(--border)', borderRadius: 6, padding: '10px 12px' }}>
|
||
<div style={{ ...modalLabelStyle, marginBottom: 8 }}>Sign {i + 1}</div>
|
||
<div className="form-group" style={{ marginBottom: 0 }}>
|
||
<label style={modalLabelStyle}>Sign Information *</label>
|
||
<input
|
||
type="text"
|
||
placeholder="e.g. Main Entry, Drive Thru"
|
||
value={sign.signName}
|
||
onChange={e => setSign(sign.id, 'signName', e.target.value)}
|
||
required
|
||
style={modalInputStyle}
|
||
/>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className="form-group">
|
||
<label style={modalLabelStyle}>{isBrandBook ? 'Notes' : 'Description'} *</label>
|
||
<textarea placeholder="Notes on the request..." value={form.description} onChange={set('description')} style={modalTextAreaStyle} required />
|
||
</div>
|
||
|
||
<FileAttachment files={files} onChange={setFiles} />
|
||
|
||
{error && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>⚠ {error}</div>}
|
||
|
||
<div className="action-buttons" style={{ justifyContent: 'flex-end' }}>
|
||
<button type="submit" className="btn btn-outline" disabled={saving}>
|
||
{saving ? 'Submitting...' : submitLabel}
|
||
</button>
|
||
{onCancel && <button type="button" className="btn btn-outline" onClick={onCancel}>Cancel</button>}
|
||
</div>
|
||
</form>
|
||
);
|
||
}
|