283511bf3a
- Fix nav links not working from profile page (useEffect infinite re-render via unstable profile object ref)
- Fix nav hover/active: gold icon highlight, no background change; active links non-clickable
- Fix hover layout shift: add border: 1px solid transparent to all interactive elements
- Header icon buttons (search, theme toggle) now highlight gold on hover
- Profile page: replace calendar with activity feed (60/40 grid), add stat cards (tasks completed, active projects, revision requests, submissions)
- Profile card: title field, icon rows for location/email/linkedin, member since + role bottom-right, edit button top-right
- Profile portrait: remove wrapper column, fix left-gap alignment
- Add profiles.title migration
- Dashboard recent activity: name → /profile/{id}, task → /requests/{id} (clickable links)
- Icon-only sidebar with gold active/hover state, pointer-events: none on active links
- layout.md updated with profile page geometry rules
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
557 lines
28 KiB
React
557 lines
28 KiB
React
import { useState, useEffect } from 'react';
|
||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||
import Layout from '../components/Layout';
|
||
import StatusBadge from '../components/StatusBadge';
|
||
import { supabase } from '../lib/supabase';
|
||
import { useAuth } from '../context/AuthContext';
|
||
import { serviceTypes } from '../data/mockData';
|
||
import { cleanupTaskStorage, deleteCompanyData } from '../lib/deleteHelpers';
|
||
import { renameClientFolder, backfillClientFolders } from '../lib/filebrowserFolders';
|
||
import { logActivity } from '../lib/activityLog';
|
||
|
||
export default function CompanyDetail() {
|
||
const { id } = useParams();
|
||
const navigate = useNavigate();
|
||
const { currentUser } = useAuth();
|
||
const isTeam = currentUser?.role === 'team';
|
||
|
||
const [company, setCompany] = useState(null);
|
||
const [projects, setProjects] = useState([]);
|
||
const [tasks, setTasks] = useState([]);
|
||
const [users, setUsers] = useState([]);
|
||
const [availableUsers, setAvailableUsers] = useState([]);
|
||
const [prices, setPrices] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [tab, setTab] = useState('users');
|
||
const [savingPrice, setSavingPrice] = useState(null);
|
||
const [assigning, setAssigning] = useState(false);
|
||
const [showNewProject, setShowNewProject] = useState(false);
|
||
const [newProjectName, setNewProjectName] = useState('');
|
||
const [savingProject, setSavingProject] = useState(false);
|
||
const [editingName, setEditingName] = useState(false);
|
||
const [nameVal, setNameVal] = useState('');
|
||
const [savingName, setSavingName] = useState(false);
|
||
const [editingUserId, setEditingUserId] = useState(null);
|
||
const [editUserVal, setEditUserVal] = useState('');
|
||
const [deletingUserId, setDeletingUserId] = useState(null);
|
||
|
||
async function load() {
|
||
const [{ data: co }, { data: p }, { data: pr }, { data: memberRows }, { data: allUsers }, { data: t }] = await Promise.all([
|
||
supabase.from('companies').select('*').eq('id', id).single(),
|
||
supabase.from('projects').select('*').eq('company_id', id).order('created_at', { ascending: false }),
|
||
supabase.from('company_prices').select('*').eq('company_id', id),
|
||
supabase.from('company_members').select('profile_id, created_at, profile:profiles(id, name, email, created_at, role, company_id)').eq('company_id', id),
|
||
supabase.from('profiles').select('id, name, email, created_at, role, company_id').eq('role', 'client').order('name'),
|
||
supabase.from('tasks').select('*, project:projects!inner(company_id)').eq('project.company_id', id),
|
||
]);
|
||
const assignedMap = new Map();
|
||
(memberRows || []).forEach(row => {
|
||
if (row.profile?.role === 'client') assignedMap.set(row.profile.id, { ...row.profile, membership_created_at: row.created_at });
|
||
});
|
||
(allUsers || []).filter(user => user.company_id === id).forEach(user => {
|
||
if (!assignedMap.has(user.id)) assignedMap.set(user.id, user);
|
||
});
|
||
const assignedUsers = [...assignedMap.values()].sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||
const assignedIds = new Set(assignedUsers.map(user => user.id));
|
||
setCompany(co);
|
||
setProjects(p || []);
|
||
setPrices(pr || []);
|
||
setUsers(assignedUsers);
|
||
setAvailableUsers((allUsers || []).filter(user => !assignedIds.has(user.id)));
|
||
setTasks(t || []);
|
||
setLoading(false);
|
||
}
|
||
|
||
useEffect(() => {
|
||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||
load();
|
||
}, [id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
const handleCompanyNameSave = async (e) => {
|
||
e.preventDefault();
|
||
if (!nameVal.trim()) return;
|
||
setSavingName(true);
|
||
const oldName = company.name;
|
||
await supabase.from('companies').update({ name: nameVal.trim() }).eq('id', id);
|
||
renameClientFolder(oldName, nameVal.trim()).catch(() => {});
|
||
backfillClientFolders().catch(() => {});
|
||
setCompany(c => ({ ...c, name: nameVal.trim() }));
|
||
setEditingName(false);
|
||
setSavingName(false);
|
||
};
|
||
|
||
const handleEditUserSave = async (userId) => {
|
||
if (!editUserVal.trim()) return;
|
||
await supabase.from('profiles').update({ name: editUserVal.trim() }).eq('id', userId);
|
||
setUsers(prev => prev.map(u => u.id === userId ? { ...u, name: editUserVal.trim() } : u));
|
||
setEditingUserId(null);
|
||
};
|
||
|
||
const handleDeleteUser = async (user) => {
|
||
if (!window.confirm(`Delete "${user.name}"? This will permanently remove their account and all access. This cannot be undone.`)) return;
|
||
setDeletingUserId(user.id);
|
||
const { data, error } = await supabase.functions.invoke('delete-user', { body: { user_id: user.id } });
|
||
const errBody = error?.context ? await error.context.json().catch(() => null) : null;
|
||
const errMsg = errBody?.error || data?.error || error?.message;
|
||
if (errMsg) { alert(`Failed to delete user: ${errMsg}`); setDeletingUserId(null); return; }
|
||
setUsers(prev => prev.filter(u => u.id !== user.id));
|
||
setAvailableUsers(prev => prev.filter(u => u.id !== user.id));
|
||
setDeletingUserId(null);
|
||
};
|
||
|
||
const handleAssignUser = async (userId) => {
|
||
setAssigning(true);
|
||
const user = availableUsers.find(u => u.id === userId);
|
||
const { error } = await supabase
|
||
.from('company_members')
|
||
.upsert({ company_id: id, profile_id: userId }, { onConflict: 'company_id,profile_id' });
|
||
if (error) {
|
||
alert('Failed to assign user. Please try again.');
|
||
setAssigning(false);
|
||
return;
|
||
}
|
||
if (user && !user.company_id) {
|
||
await supabase.from('profiles').update({ company_id: id }).eq('id', userId);
|
||
}
|
||
if (user) {
|
||
setUsers(prev => [...prev, { ...user, company_id: user.company_id || id, created_at: user.created_at || new Date().toISOString() }]
|
||
.sort((a, b) => (a.name || '').localeCompare(b.name || '')));
|
||
setAvailableUsers(prev => prev.filter(u => u.id !== userId));
|
||
}
|
||
setAssigning(false);
|
||
};
|
||
|
||
const handleRemoveUser = async (userId) => {
|
||
if (!window.confirm('Remove this user from the company? They will lose access to this company data.')) return;
|
||
await supabase.from('company_members').delete().eq('company_id', id).eq('profile_id', userId);
|
||
const user = users.find(u => u.id === userId);
|
||
if (user?.company_id === id) {
|
||
const { data: nextMembership } = await supabase
|
||
.from('company_members')
|
||
.select('company_id')
|
||
.eq('profile_id', userId)
|
||
.neq('company_id', id)
|
||
.limit(1)
|
||
.maybeSingle();
|
||
await supabase.from('profiles').update({ company_id: nextMembership?.company_id || null }).eq('id', userId);
|
||
}
|
||
if (user) {
|
||
setUsers(prev => prev.filter(u => u.id !== userId));
|
||
setAvailableUsers(prev => [...prev, { ...user, company_id: user.company_id === id ? null : user.company_id }]
|
||
.sort((a, b) => (a.name || '').localeCompare(b.name || '')));
|
||
}
|
||
};
|
||
|
||
const handleDeleteCompany = async () => {
|
||
if (!window.confirm(`Delete "${company.name}"? This will permanently delete all projects, jobs, files, and data. This cannot be undone.`)) return;
|
||
await deleteCompanyData(id);
|
||
navigate('/company');
|
||
};
|
||
|
||
const handleDeleteProject = async (project) => {
|
||
if (!window.confirm(`Delete project "${project.name}"? All jobs will be removed and the project folder will be moved to Archive.`)) return;
|
||
try {
|
||
const { data: { session } } = await supabase.auth.getSession();
|
||
const res = await fetch(`/api/delete-project?id=${project.id}`, {
|
||
method: 'DELETE',
|
||
headers: { Authorization: `Bearer ${session?.access_token}` },
|
||
});
|
||
if (!res.ok) { const d = await res.json(); throw new Error(d.error || 'Delete failed'); }
|
||
} catch (err) {
|
||
alert(`Failed to delete project: ${err.message}`);
|
||
return;
|
||
}
|
||
setProjects(prev => prev.filter(p => p.id !== project.id));
|
||
setTasks(prev => prev.filter(t => t.project_id !== project.id));
|
||
};
|
||
|
||
const handleCreateProject = async (e) => {
|
||
e.preventDefault();
|
||
if (!newProjectName.trim()) return;
|
||
setSavingProject(true);
|
||
const { data } = await supabase.from('projects').insert({
|
||
company_id: id,
|
||
name: newProjectName.trim(),
|
||
status: 'active',
|
||
}).select().single();
|
||
if (data) {
|
||
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'project_created', projectId: data.id, projectName: data.name });
|
||
setProjects(prev => [data, ...prev]);
|
||
setNewProjectName('');
|
||
setShowNewProject(false);
|
||
// Fire-and-forget: create project folder in FileBrowser
|
||
supabase.auth.getSession().then(({ data: { session } }) => {
|
||
if (session?.access_token && company?.name) {
|
||
fetch('/api/sync-project-folder', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.access_token}` },
|
||
body: JSON.stringify({ type: 'INSERT', record: { name: data.name, company_name: company.name } }),
|
||
}).catch(() => {});
|
||
}
|
||
});
|
||
}
|
||
setSavingProject(false);
|
||
};
|
||
|
||
const getPrice = (serviceType, priceType) =>
|
||
prices.find(p => p.service_type === serviceType && p.price_type === priceType)?.price ?? '';
|
||
|
||
const handlePriceChange = (serviceType, priceType, value) => {
|
||
setPrices(prev => {
|
||
const existing = prev.find(p => p.service_type === serviceType && p.price_type === priceType);
|
||
if (existing) return prev.map(p => p.service_type === serviceType && p.price_type === priceType ? { ...p, price: value } : p);
|
||
return [...prev, { service_type: serviceType, price_type: priceType, price: value, company_id: id }];
|
||
});
|
||
};
|
||
|
||
const handlePriceSave = async (serviceType) => {
|
||
setSavingPrice(serviceType);
|
||
for (const priceType of ['new', 'revision']) {
|
||
const priceVal = getPrice(serviceType, priceType);
|
||
const existing = prices.find(p => p.service_type === serviceType && p.price_type === priceType && p.id);
|
||
if (existing) {
|
||
const { error } = await supabase.from('company_prices').update({ price: Number(priceVal) }).eq('id', existing.id);
|
||
if (error) { setSavingPrice(null); alert('Failed to save price. Please try again.'); return; }
|
||
} else if (priceVal !== '') {
|
||
const { data, error } = await supabase.from('company_prices').insert({
|
||
company_id: id, service_type: serviceType, price_type: priceType, price: Number(priceVal),
|
||
}).select().single();
|
||
if (error) { setSavingPrice(null); alert('Failed to save price. Please try again.'); return; }
|
||
if (data) setPrices(prev => [...prev.filter(p => !(p.service_type === serviceType && p.price_type === priceType && !p.id)), data]);
|
||
}
|
||
}
|
||
setSavingPrice(null);
|
||
};
|
||
|
||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||
if (!company) return <Layout><p>Company not found.</p></Layout>;
|
||
|
||
const activeTasks = tasks.filter(t => t.status !== 'client_approved');
|
||
const completedTasks = tasks.filter(t => t.status === 'client_approved');
|
||
|
||
return (
|
||
<Layout>
|
||
<button className="back-link" onClick={() => navigate('/company')}>← Back to Companies</button>
|
||
|
||
<div className="page-header">
|
||
<div>
|
||
{isTeam && editingName ? (
|
||
<form onSubmit={handleCompanyNameSave} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||
<input
|
||
type="text"
|
||
value={nameVal}
|
||
onChange={e => setNameVal(e.target.value)}
|
||
autoFocus
|
||
required
|
||
style={{ fontSize: 22, fontWeight: 400, padding: '4px 10px', margin: 0, width: 260 }}
|
||
/>
|
||
<button type="submit" className="btn btn-primary btn-sm" disabled={savingName}>{savingName ? '...' : 'Save'}</button>
|
||
<button type="button" className="btn btn-outline btn-sm" onClick={() => setEditingName(false)}>Cancel</button>
|
||
</form>
|
||
) : (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||
<div className="page-title">{company.name}</div>
|
||
{isTeam && <button className="btn-icon" title="Edit" onClick={() => { setNameVal(company.name); setEditingName(true); }}><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>}
|
||
</div>
|
||
)}
|
||
<div className="page-subtitle">
|
||
{users[0]?.name && <>{users[0].name}</>}
|
||
{users[0]?.name && (company.phone || company.address) && ' · '}
|
||
{company.phone && <>{company.phone}</>}
|
||
{company.phone && company.address && ' · '}
|
||
{company.address && <>{company.address}</>}
|
||
{!users[0]?.name && !company.phone && !company.address && 'No contact info'}
|
||
</div>
|
||
</div>
|
||
{isTeam && <button
|
||
className="btn-icon btn-icon-danger"
|
||
onClick={handleDeleteCompany}
|
||
title="Delete Company">✕</button>}
|
||
</div>
|
||
|
||
<div className="stats-grid" style={{ marginBottom: 28 }}>
|
||
<div className="stat-card">
|
||
<div className="stat-icon">📁</div>
|
||
<div className="stat-value">{projects.length}</div>
|
||
<div className="stat-label">Projects</div>
|
||
</div>
|
||
<div className="stat-card">
|
||
<div className="stat-icon">⚡</div>
|
||
<div className="stat-value">{activeTasks.length}</div>
|
||
<div className="stat-label">Active Jobs</div>
|
||
</div>
|
||
<div className="stat-card">
|
||
<div className="stat-icon">✅</div>
|
||
<div className="stat-value">{completedTasks.length}</div>
|
||
<div className="stat-label">Completed</div>
|
||
</div>
|
||
<div className="stat-card">
|
||
<div className="stat-icon">📅</div>
|
||
<div className="stat-value" style={{ fontSize: 16 }}>{new Date(company.created_at).toLocaleDateString()}</div>
|
||
<div className="stat-label">Since</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tabs */}
|
||
<div style={{ display: 'flex', gap: 4, marginBottom: 24, flexWrap: 'wrap' }}>
|
||
{(isTeam ? ['users', 'projects', 'pricing'] : ['users', 'projects']).map(t => (
|
||
<button
|
||
key={t}
|
||
onClick={() => setTab(t)}
|
||
className={`tab-btn${tab === t ? ' active' : ''}`}
|
||
style={{ textTransform: 'capitalize' }}
|
||
>
|
||
{t}
|
||
{t === 'users' && availableUsers.length > 0 && (
|
||
<span style={{ marginLeft: 6, fontSize: 10, background: tab === t ? 'rgba(0,0,0,0.3)' : 'var(--danger)', color: 'white', padding: '1px 5px', borderRadius: 4, fontWeight: 400 }}>
|
||
{availableUsers.length}
|
||
</span>
|
||
)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Users Tab */}
|
||
{tab === 'users' && (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||
<div className="card">
|
||
<div className="card-title">Assigned Users</div>
|
||
{users.length === 0 ? (
|
||
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>No users assigned to this company yet.</p>
|
||
) : (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||
{users.map((user, i) => (
|
||
<div key={user.id} style={{
|
||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||
padding: '12px 0',
|
||
borderBottom: i < users.length - 1 ? '1px solid var(--border)' : 'none',
|
||
}}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flex: 1 }}>
|
||
<div style={{
|
||
width: 32, height: 32, borderRadius: 4, background: 'var(--accent)',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
fontSize: 12, fontWeight: 400, color: '#111', flexShrink: 0,
|
||
}}>
|
||
{user.name?.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
|
||
</div>
|
||
<div style={{ flex: 1 }}>
|
||
{editingUserId === user.id ? (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||
<input
|
||
type="text"
|
||
value={editUserVal}
|
||
onChange={e => setEditUserVal(e.target.value)}
|
||
autoFocus
|
||
style={{ margin: 0, fontSize: 13, padding: '3px 8px', width: 180 }}
|
||
onKeyDown={e => { if (e.key === 'Enter') handleEditUserSave(user.id); if (e.key === 'Escape') setEditingUserId(null); }}
|
||
/>
|
||
<button className="btn btn-primary btn-sm" onClick={() => handleEditUserSave(user.id)}>Save</button>
|
||
<button className="btn btn-outline btn-sm" onClick={() => setEditingUserId(null)}>Cancel</button>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div style={{ fontWeight: 400, fontSize: 14 }}>{user.name}</div>
|
||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{user.email || '—'}</div>
|
||
<div style={{ fontSize: 11, color: 'var(--text-muted)', textTransform: 'capitalize', marginTop: 2 }}>{user.role || '—'}</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{isTeam && editingUserId !== user.id && (
|
||
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
|
||
<button
|
||
className="btn-icon"
|
||
title="Edit"
|
||
onClick={() => { setEditingUserId(user.id); setEditUserVal(user.name); }}
|
||
><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>
|
||
<button
|
||
className="btn btn-outline btn-sm"
|
||
onClick={() => handleRemoveUser(user.id)}
|
||
>Unassign</button>
|
||
<button
|
||
className="btn-icon btn-icon-danger"
|
||
title="Delete"
|
||
onClick={() => handleDeleteUser(user)}
|
||
disabled={deletingUserId === user.id}
|
||
>
|
||
{deletingUserId === user.id ? '...' : '✕'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{isTeam && availableUsers.length > 0 && (
|
||
<div className="card">
|
||
<div className="card-title">Available Users</div>
|
||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 14 }}>
|
||
Add an existing client user to this company. External subcontractors are assigned to projects instead.
|
||
</p>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||
{availableUsers.map(user => (
|
||
<div key={user.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
|
||
<div style={{ flex: 1 }}>
|
||
{editingUserId === user.id ? (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||
<input
|
||
type="text"
|
||
value={editUserVal}
|
||
onChange={e => setEditUserVal(e.target.value)}
|
||
autoFocus
|
||
style={{ margin: 0, fontSize: 13, padding: '3px 8px', width: 180 }}
|
||
onKeyDown={e => { if (e.key === 'Enter') handleEditUserSave(user.id); if (e.key === 'Escape') setEditingUserId(null); }}
|
||
/>
|
||
<button className="btn btn-primary btn-sm" onClick={() => handleEditUserSave(user.id)}>Save</button>
|
||
<button className="btn btn-outline btn-sm" onClick={() => setEditingUserId(null)}>Cancel</button>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div style={{ fontWeight: 400, fontSize: 13 }}>{user.name}</div>
|
||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{user.email}</div>
|
||
<div style={{ fontSize: 11, color: 'var(--text-muted)', textTransform: 'capitalize', marginTop: 2 }}>{user.role || '—'}</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
{editingUserId !== user.id && (
|
||
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
|
||
<button className="btn btn-primary btn-sm" onClick={() => handleAssignUser(user.id)} disabled={assigning}>
|
||
Assign to {company.name}
|
||
</button>
|
||
<button className="btn-icon" title="Edit" onClick={() => { setEditingUserId(user.id); setEditUserVal(user.name); }}><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>
|
||
<button
|
||
className="btn-icon btn-icon-danger"
|
||
title="Delete"
|
||
onClick={() => handleDeleteUser(user)}
|
||
disabled={deletingUserId === user.id}
|
||
>{deletingUserId === user.id ? '...' : '✕'}</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Projects Tab */}
|
||
{tab === 'projects' && (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||
{isTeam && <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||
<button className="btn btn-primary btn-sm" onClick={() => setShowNewProject(s => !s)}>
|
||
{showNewProject ? 'Cancel' : '+ New Project'}
|
||
</button>
|
||
</div>}
|
||
|
||
{showNewProject && (
|
||
<div className="card">
|
||
<div className="card-title">New Project</div>
|
||
<form onSubmit={handleCreateProject} style={{ display: 'flex', gap: 8, alignItems: 'flex-end' }}>
|
||
<div className="form-group" style={{ flex: 1, marginBottom: 0 }}>
|
||
<label>Project Name *</label>
|
||
<input
|
||
type="text"
|
||
placeholder="e.g. Brand Identity 2026"
|
||
value={newProjectName}
|
||
onChange={e => setNewProjectName(e.target.value)}
|
||
required
|
||
autoFocus
|
||
/>
|
||
</div>
|
||
<button type="submit" className="btn btn-primary" disabled={savingProject || !newProjectName.trim()}>
|
||
{savingProject ? 'Creating...' : 'Create'}
|
||
</button>
|
||
</form>
|
||
</div>
|
||
)}
|
||
|
||
{projects.length === 0 ? (
|
||
<div className="empty-state">
|
||
<h3>No projects yet</h3>
|
||
<p>Create a project to start adding jobs.</p>
|
||
</div>
|
||
) : (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||
{projects.map(project => {
|
||
const projectTasks = tasks.filter(t => t.project_id === project.id);
|
||
const active = projectTasks.filter(t => t.status !== 'client_approved').length;
|
||
const done = projectTasks.filter(t => t.status === 'client_approved').length;
|
||
return (
|
||
<div key={project.id} className="interactive-surface" style={{ display: 'flex', alignItems: 'center', background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, overflow: 'hidden' }}>
|
||
<Link to={`/projects/${project.id}`} className="interactive-row" style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px', textDecoration: 'none', cursor: 'pointer' }}>
|
||
<div>
|
||
<div style={{ fontWeight: 400, fontSize: 14, color: 'var(--text-primary)' }}>{project.name}</div>
|
||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 3 }}>
|
||
{projectTasks.length} job{projectTasks.length !== 1 ? 's' : ''}
|
||
{active > 0 && <> · <span style={{ color: 'var(--accent)' }}>{active} active</span></>}
|
||
{done > 0 && <> · {done} done</>}
|
||
{' · '}Started {new Date(project.created_at).toLocaleDateString()}
|
||
</div>
|
||
</div>
|
||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>›</span>
|
||
</Link>
|
||
{isTeam && <button
|
||
type="button"
|
||
onClick={() => handleDeleteProject(project)}
|
||
style={{ background: 'none', border: 'none', borderLeft: '1px solid var(--border)', color: 'var(--danger, #dc2626)', cursor: 'pointer', fontSize: 16, padding: '0 14px', alignSelf: 'stretch', display: 'flex', alignItems: 'center' }}
|
||
title="Delete project"
|
||
>✕</button>}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Pricing Tab */}
|
||
{tab === 'pricing' && (
|
||
<div className="card">
|
||
<div className="card-title">Price List — {company.name}</div>
|
||
<p style={{ fontSize: 13, color: 'var(--text-muted)', marginBottom: 16 }}>
|
||
Set prices per service type for this company. These auto-fill when creating an invoice.
|
||
</p>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 130px 130px 60px', gap: 8, marginBottom: 8, alignItems: 'center' }}>
|
||
<div />
|
||
{['New', 'Revision'].map(label => (
|
||
<div key={label} style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', textAlign: 'right' }}>{label}</div>
|
||
))}
|
||
<div />
|
||
</div>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||
{serviceTypes.map(serviceType => (
|
||
<div key={serviceType} style={{ display: 'grid', gridTemplateColumns: '1fr 130px 130px 60px', gap: 8, alignItems: 'center' }}>
|
||
<div style={{ fontSize: 14, fontWeight: 500 }}>{serviceType}</div>
|
||
{['new', 'revision'].map(priceType => (
|
||
<div key={priceType} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||
<span style={{ color: 'var(--text-muted)', fontSize: 14 }}>$</span>
|
||
<input
|
||
type="number"
|
||
min="0"
|
||
step="0.01"
|
||
placeholder="0.00"
|
||
value={getPrice(serviceType, priceType)}
|
||
onChange={e => handlePriceChange(serviceType, priceType, e.target.value)}
|
||
style={{ margin: 0, width: '100%', textAlign: 'right' }}
|
||
/>
|
||
</div>
|
||
))}
|
||
<button
|
||
className="btn btn-outline btn-sm"
|
||
onClick={() => handlePriceSave(serviceType)}
|
||
disabled={savingPrice === serviceType}
|
||
>
|
||
{savingPrice === serviceType ? '...' : 'Save'}
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</Layout>
|
||
);
|
||
}
|