Files
fourge-portal/src/components/RequestForm.jsx
T
Krao Hasanee 91045a1897 redesign: rename Project→Client, finance single-card + filters, mobile tasks, sticky headers
- Rename "Project" → "Client" across UI (labels + /projects→/clients routes with redirect); normalize company-meaning labels to "Company"
- Finance tabs: merge dual cards into one per tab, add header column filters (expenses/subs/invoices), overview as 3-section card, move +Expense/+Invoice to card right edge
- Tasks: responsive mobile stats grid, progressive column hiding (min Status+Name+Assigned), sidebar mobile footer (profile/theme/signout)
- Hide Company column for single-company users; +Task shows disabled Company field instead of hiding
- Sticky table headers site-wide with opaque card background
- Pin dev server to port 5173

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 09:39:54 -04:00

365 lines
15 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 modalActionButtonStyle = { width: 132, justifyContent: 'center', flex: '0 0 132px' };
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),
requestedBy: showRequester ? (currentUser?.id || '') : '',
...(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 [localError, setLocalError] = useState('');
const usesSignFields = form.serviceType === 'Brand Book';
useEffect(() => {
supabase.from('sign_families').select('name').order('sort_order').then(({ data }) => setSignFamilies((data || []).map(r => r.name)));
}, []);
useEffect(() => {
if (!usesSignFields) { setSignCount(''); setSigns([]); }
}, [usesSignFields]);
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;
// Single-company user: lock selection to their one company.
useEffect(() => {
if (companies.length === 1 && !form.companyId) {
setForm(f => ({ ...f, companyId: companies[0].id }));
}
}, [companies, form.companyId]);
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) => {
if (localError) setLocalError('');
setForm(f => ({ ...f, [field]: e.target.value }));
};
const setSign = (id, field, value) => {
if (localError) setLocalError('');
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 requesterOptions = [
...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)` }] : []),
...companyUsers.filter(u => u.id !== currentUser?.id),
];
const handleSubmit = (e) => {
e.preventDefault();
setLocalError('');
const selectedRequester = showRequester
? requesterOptions.find(option => option.id === form.requestedBy) || null
: null;
const requestedBy = showRequester
? (selectedRequester?.id || '')
: (currentUser?.id || '');
const requestedByName = showRequester
? ((selectedRequester?.name || '').replace(/\s+\(You\)$/, '') || '')
: (currentUser?.name || '');
let normalizedSignCount = null;
let normalizedSigns = [];
if (usesSignFields) {
normalizedSignCount = parseInt(signCount, 10) || null;
const formData = new FormData(e.currentTarget);
normalizedSigns = Array.from({ length: normalizedSignCount || 0 }, (_, index) => {
const stateSign = signs[index] || {};
const domValue = formData.get(`sign_info_${index + 1}`);
const signName = typeof domValue === 'string'
? domValue.trim()
: String(stateSign.signName || '').trim();
return {
id: stateSign.id || `sign-${index + 1}`,
signName,
signFamily: stateSign.signFamily || '',
};
}).filter(sign => sign.signName || sign.signFamily);
if ((normalizedSignCount || 0) > 0 && normalizedSigns.length !== normalizedSignCount) {
setLocalError('Please fill in the sign information for each sign before submitting.');
return;
}
}
onSubmit(
{
...form,
companyId,
requestedBy,
requestedByName,
signCount: usesSignFields ? normalizedSignCount : null,
signs: usesSignFields ? normalizedSigns : [],
},
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>
) : companies.length === 1 ? (
<div className="form-group">
<label style={modalLabelStyle}>Company</label>
<input value={companies[0].name} disabled style={{ ...modalInputStyle, opacity: 0.5, cursor: 'not-allowed' }} />
</div>
) : <div />}
<div className="form-group">
<label style={modalLabelStyle}>Client {!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 client 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>
{showRequester && (
<div className="form-group">
<label style={modalLabelStyle}>Requested By *</label>
<select
value={form.requestedBy}
onChange={set('requestedBy')}
required
disabled={!companyId}
style={modalInputStyle}
>
<option value="">{companyId ? 'Select requester...' : 'Select company first'}</option>
{requesterOptions.map(user => (
<option key={user.id} value={user.id}>{user.name}</option>
))}
</select>
</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>
{usesSignFields && (
<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>
)}
{usesSignFields && 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"
name={`sign_info_${i + 1}`}
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}>{usesSignFields ? 'Notes' : 'Description'} *</label>
<textarea placeholder="Notes on the request..." value={form.description} onChange={set('description')} style={modalTextAreaStyle} required />
</div>
<FileAttachment files={files} onChange={setFiles} />
{(localError || error) && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}> {localError || error}</div>}
<div className="action-buttons modal-action-row" style={{ justifyContent: 'flex-end' }}>
<button type="submit" className="btn btn-outline" disabled={saving} style={modalActionButtonStyle}>
{saving ? 'Submitting...' : submitLabel}
</button>
{onCancel && <button type="button" className="btn btn-outline" onClick={onCancel} style={modalActionButtonStyle}>Cancel</button>}
</div>
</form>
);
}