91045a1897
- 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>
365 lines
15 KiB
React
365 lines
15 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 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>
|
||
);
|
||
}
|