Files
fourge-portal/src/components/RequestForm.jsx
T
Krao Hasanee 70ad8d0cef feat: project detail overhaul, realtime updates, avatar RLS fix
- 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>
2026-05-31 16:55:58 -04:00

284 lines
12 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}