eee0885811
- Remove recursive directory size calculations (single Seafile API call per list) - Remove 'Used in this location' usage display - Fix move using v2 per-type endpoints instead of broken batch endpoint - Send entry type from frontend for correct move routing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
680 lines
31 KiB
React
680 lines
31 KiB
React
import { useState, useEffect, useRef } from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import Layout from '../../components/Layout';
|
||
import { supabase } from '../../lib/supabase';
|
||
import { deleteCompanyData } from '../../lib/deleteHelpers';
|
||
import { restoreCompanyArchive } from '../../lib/archiveHelpers';
|
||
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
||
import { syncSeafileFolders } from '../../lib/seafileFolders';
|
||
|
||
function getRoleLabel(role) {
|
||
if (role === 'external') return 'Subcontractor';
|
||
if (role === 'client') return 'Client';
|
||
if (role === 'team') return 'Team';
|
||
return role || '—';
|
||
}
|
||
|
||
export default function Companies() {
|
||
const navigate = useNavigate();
|
||
const cached = readPageCache('team_companies');
|
||
const [companies, setCompanies] = useState(() => cached?.companies || []);
|
||
const [profiles, setProfiles] = useState(() => cached?.profiles || []);
|
||
const [companyMemberships, setCompanyMemberships] = useState(() => cached?.companyMemberships || []);
|
||
const [loading, setLoading] = useState(() => !cached);
|
||
const [showNew, setShowNew] = useState(false);
|
||
const [newForm, setNewForm] = useState({ name: '', phone: '', address: '' });
|
||
const [showNewUser, setShowNewUser] = useState(false);
|
||
const [userForm, setUserForm] = useState({ name: '', email: '', password: '', company_id: '', role: 'client' });
|
||
const [saving, setSaving] = useState(false);
|
||
const [userError, setUserError] = useState('');
|
||
const [editingUserId, setEditingUserId] = useState(null);
|
||
const [editUserVal, setEditUserVal] = useState('');
|
||
const [deletingUserId, setDeletingUserId] = useState(null);
|
||
const [restoringArchive, setRestoringArchive] = useState(false);
|
||
const [restoreStatus, setRestoreStatus] = useState('');
|
||
const [filterCompany, setFilterCompany] = useState('');
|
||
const [activeTab, setActiveTab] = useState('companies');
|
||
const restoreInputRef = useRef(null);
|
||
|
||
async function load() {
|
||
const [{ data: co }, { data: prof }, { data: memberships }] = await Promise.all([
|
||
supabase.from('companies').select('*').order('name'),
|
||
supabase.from('profiles').select('id, name, email, company_id, role').in('role', ['client', 'external']).order('name'),
|
||
supabase.from('company_members').select('company_id, profile_id'),
|
||
]);
|
||
setCompanies(co || []);
|
||
setProfiles(prof || []);
|
||
setCompanyMemberships(memberships || []);
|
||
writePageCache('team_companies', { companies: co || [], profiles: prof || [], companyMemberships: memberships || [] });
|
||
setLoading(false);
|
||
}
|
||
|
||
useEffect(() => {
|
||
load();
|
||
}, []);
|
||
|
||
const handleCreate = async (e) => {
|
||
e.preventDefault();
|
||
if (!newForm.name.trim()) return;
|
||
setSaving(true);
|
||
const { data } = await supabase.from('companies').insert({
|
||
name: newForm.name.trim(),
|
||
phone: newForm.phone.trim(),
|
||
address: newForm.address.trim(),
|
||
}).select().single();
|
||
setSaving(false);
|
||
if (data) {
|
||
syncSeafileFolders().catch((error) => console.warn('Seafile folder sync failed:', error.message));
|
||
setShowNew(false);
|
||
setNewForm({ name: '', phone: '', address: '' });
|
||
navigate(`/companies/${data.id}`);
|
||
}
|
||
};
|
||
|
||
const handleDeleteCompany = async (company) => {
|
||
if (!window.confirm(`Delete "${company.name}"? This will permanently delete all projects, jobs, files, and data for this company. This cannot be undone.`)) return;
|
||
await deleteCompanyData(company.id);
|
||
setCompanies(prev => prev.filter(c => c.id !== company.id));
|
||
load();
|
||
};
|
||
|
||
const handleRestoreArchive = async (e) => {
|
||
const file = e.target.files?.[0];
|
||
e.target.value = '';
|
||
if (!file) return;
|
||
|
||
setRestoringArchive(true);
|
||
try {
|
||
const result = await restoreCompanyArchive(file, { onProgress: setRestoreStatus });
|
||
await load();
|
||
|
||
const missing = [];
|
||
if (result.missingProfiles.companyProfiles) missing.push(`${result.missingProfiles.companyProfiles} client profiles were not re-linked`);
|
||
if (result.missingProfiles.projectMembers) missing.push(`${result.missingProfiles.projectMembers} external project memberships were skipped`);
|
||
if (result.missingProfiles.taskAssignments) missing.push(`${result.missingProfiles.taskAssignments} task assignments were cleared`);
|
||
if (result.missingProfiles.submissions) missing.push(`${result.missingProfiles.submissions} submission user links were cleared`);
|
||
if (result.missingProfiles.invoices) missing.push(`${result.missingProfiles.invoices} invoice creator links were cleared`);
|
||
|
||
alert(
|
||
missing.length
|
||
? `Restored ${result.companyCount || 1} compan${(result.companyCount || 1) === 1 ? 'y' : 'ies'}.\n\nNote: ${missing.join('; ')}.`
|
||
: `Restored ${result.companyCount || 1} compan${(result.companyCount || 1) === 1 ? 'y' : 'ies'} successfully.`
|
||
);
|
||
} catch (error) {
|
||
alert(`Restore failed: ${error.message}`);
|
||
} finally {
|
||
setRestoringArchive(false);
|
||
setRestoreStatus('');
|
||
}
|
||
};
|
||
|
||
const handleEditUserSave = async (userId) => {
|
||
if (!editUserVal.trim()) return;
|
||
await supabase.from('profiles').update({ name: editUserVal.trim() }).eq('id', userId);
|
||
setProfiles(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. 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; }
|
||
setProfiles(prev => prev.filter(u => u.id !== user.id));
|
||
setDeletingUserId(null);
|
||
};
|
||
|
||
const handleCreateUser = async (e) => {
|
||
e.preventDefault();
|
||
setUserError('');
|
||
setSaving(true);
|
||
const { data, error } = await supabase.functions.invoke('create-user', {
|
||
body: {
|
||
name: userForm.name.trim(),
|
||
email: userForm.email.trim(),
|
||
password: userForm.password,
|
||
role: userForm.role,
|
||
company_id: userForm.role === 'client' ? (userForm.company_id || null) : null,
|
||
},
|
||
});
|
||
setSaving(false);
|
||
const errBody = error?.context ? await error.context.json().catch(() => null) : null;
|
||
const errMsg = errBody?.error || data?.error || error?.message;
|
||
if (errMsg) {
|
||
setUserError(errMsg);
|
||
return;
|
||
}
|
||
setShowNewUser(false);
|
||
setUserForm({ name: '', email: '', password: '', company_id: '', role: 'client' });
|
||
syncSeafileFolders().catch((syncError) => console.warn('Seafile folder sync failed:', syncError.message));
|
||
load();
|
||
};
|
||
|
||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||
|
||
const getProfileCompanyIds = (profile) => {
|
||
const ids = new Set(
|
||
companyMemberships
|
||
.filter(membership => membership.profile_id === profile.id && profile.role === 'client')
|
||
.map(membership => membership.company_id)
|
||
);
|
||
if (profile.role === 'client' && profile.company_id) ids.add(profile.company_id);
|
||
return [...ids];
|
||
};
|
||
|
||
const clientProfiles = profiles.filter(profile => profile.role === 'client');
|
||
const subcontractors = profiles.filter(profile => profile.role === 'external');
|
||
const unassigned = clientProfiles.filter(profile => getProfileCompanyIds(profile).length === 0);
|
||
|
||
return (
|
||
<Layout>
|
||
<div className="page-header">
|
||
<div>
|
||
<div className="page-title">Clients & Users</div>
|
||
<div className="page-subtitle">
|
||
{companies.length} {companies.length !== 1 ? 'companies' : 'company'}
|
||
<span style={{ marginLeft: 10 }}>
|
||
· {clientProfiles.length} client user{clientProfiles.length !== 1 ? 's' : ''}
|
||
</span>
|
||
<span style={{ marginLeft: 10 }}>
|
||
· {subcontractors.length} subcontractor{subcontractors.length !== 1 ? 's' : ''}
|
||
</span>
|
||
{unassigned.length > 0 && (
|
||
<span style={{ marginLeft: 10, color: 'var(--danger)', fontWeight: 600 }}>
|
||
· {unassigned.length} unassigned client{unassigned.length !== 1 ? 's' : ''}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<input
|
||
ref={restoreInputRef}
|
||
type="file"
|
||
accept=".zip,application/zip"
|
||
style={{ display: 'none' }}
|
||
onChange={handleRestoreArchive}
|
||
/>
|
||
</div>
|
||
|
||
<div className="stats-grid" style={{ marginBottom: 24 }}>
|
||
<div className="stat-card">
|
||
<div className="stat-icon">🏢</div>
|
||
<div className="stat-value">{companies.length}</div>
|
||
<div className="stat-label">Companies</div>
|
||
</div>
|
||
<div className="stat-card">
|
||
<div className="stat-icon">👥</div>
|
||
<div className="stat-value">{clientProfiles.length}</div>
|
||
<div className="stat-label">Client Users</div>
|
||
</div>
|
||
<div className="stat-card">
|
||
<div className="stat-icon">🧾</div>
|
||
<div className="stat-value">{subcontractors.length}</div>
|
||
<div className="stat-label">Subcontractors</div>
|
||
</div>
|
||
<div className="stat-card">
|
||
<div className="stat-icon">⚠️</div>
|
||
<div className="stat-value">{unassigned.length}</div>
|
||
<div className="stat-label">Unassigned Clients</div>
|
||
</div>
|
||
</div>
|
||
|
||
{showNewUser && (
|
||
<div className="card" style={{ marginBottom: 24, maxWidth: 480 }}>
|
||
<div className="card-title">New User</div>
|
||
<form onSubmit={handleCreateUser}>
|
||
<div className="grid-2">
|
||
<div className="form-group">
|
||
<label>Full Name *</label>
|
||
<input type="text" placeholder="Jane Smith" value={userForm.name}
|
||
onChange={e => setUserForm(f => ({ ...f, name: e.target.value }))} required autoFocus />
|
||
</div>
|
||
<div className="form-group">
|
||
<label>Email *</label>
|
||
<input type="email" placeholder="jane@acme.com" value={userForm.email}
|
||
onChange={e => setUserForm(f => ({ ...f, email: e.target.value }))} required />
|
||
</div>
|
||
</div>
|
||
<div className="grid-2">
|
||
<div className="form-group">
|
||
<label>Password *</label>
|
||
<input type="password" placeholder="Temporary password" value={userForm.password}
|
||
onChange={e => setUserForm(f => ({ ...f, password: e.target.value }))} required minLength={6} />
|
||
</div>
|
||
<div className="form-group">
|
||
<label>Role *</label>
|
||
<select value={userForm.role} onChange={e => setUserForm(f => ({ ...f, role: e.target.value, company_id: '' }))}>
|
||
<option value="client">Client</option>
|
||
<option value="team">Team</option>
|
||
<option value="external">Subcontractor</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
{userForm.role === 'client' && (
|
||
<div className="form-group">
|
||
<label>Assign to Company</label>
|
||
<select value={userForm.company_id} onChange={e => setUserForm(f => ({ ...f, company_id: e.target.value }))}>
|
||
<option value="">No company yet</option>
|
||
{companies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||
</select>
|
||
</div>
|
||
)}
|
||
{userError && <p style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>{userError}</p>}
|
||
<div className="action-buttons">
|
||
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||
{saving ? 'Creating...' : 'Create User'}
|
||
</button>
|
||
<button type="button" className="btn btn-outline" onClick={() => setShowNewUser(false)}>Cancel</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
)}
|
||
|
||
{showNew && (
|
||
<div className="card" style={{ marginBottom: 24, maxWidth: 480 }}>
|
||
<div className="card-title">New Company</div>
|
||
<form onSubmit={handleCreate}>
|
||
<div className="form-group">
|
||
<label>Company Name *</label>
|
||
<input
|
||
type="text"
|
||
placeholder="Acme Corp"
|
||
value={newForm.name}
|
||
onChange={e => setNewForm(f => ({ ...f, name: e.target.value }))}
|
||
required
|
||
autoFocus
|
||
/>
|
||
</div>
|
||
<div className="grid-2">
|
||
<div className="form-group">
|
||
<label>Phone</label>
|
||
<input
|
||
type="text"
|
||
placeholder="+1 (555) 000-0000"
|
||
value={newForm.phone}
|
||
onChange={e => setNewForm(f => ({ ...f, phone: e.target.value }))}
|
||
/>
|
||
</div>
|
||
<div className="form-group">
|
||
<label>Address</label>
|
||
<input
|
||
type="text"
|
||
placeholder="123 Main St, City, State"
|
||
value={newForm.address}
|
||
onChange={e => setNewForm(f => ({ ...f, address: e.target.value }))}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="action-buttons">
|
||
<button type="submit" className="btn btn-primary" disabled={saving || !newForm.name.trim()}>
|
||
{saving ? 'Creating...' : 'Create Company'}
|
||
</button>
|
||
<button type="button" className="btn btn-outline" onClick={() => setShowNew(false)}>Cancel</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
)}
|
||
|
||
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||
<div className="card-title" style={{ marginBottom: 0, display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||
{[
|
||
{ id: 'companies', label: 'Companies' },
|
||
{ id: 'clients', label: 'Clients' },
|
||
{ id: 'subcontractors', label: 'Subcontractors' },
|
||
].map((tab, index) => (
|
||
<span key={tab.id} style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||
{index > 0 && <span style={{ color: 'var(--text-muted)' }}>|</span>}
|
||
<button
|
||
type="button"
|
||
onClick={() => setActiveTab(tab.id)}
|
||
style={{
|
||
background: 'none',
|
||
border: 'none',
|
||
padding: 0,
|
||
margin: 0,
|
||
cursor: 'pointer',
|
||
color: activeTab === tab.id ? 'var(--text-primary)' : 'var(--text-muted)',
|
||
font: 'inherit',
|
||
textTransform: 'inherit',
|
||
letterSpacing: 'inherit',
|
||
}}
|
||
>
|
||
{tab.label}
|
||
</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
{activeTab === 'companies' && (
|
||
<button
|
||
className="btn btn-primary btn-sm"
|
||
onClick={() => { setShowNew(true); setShowNewUser(false); }}
|
||
>
|
||
+ New Company
|
||
</button>
|
||
)}
|
||
{activeTab === 'clients' && (
|
||
<button
|
||
className="btn btn-primary btn-sm"
|
||
onClick={() => { setShowNewUser(true); setShowNew(false); setUserForm(f => ({ ...f, role: 'client' })); }}
|
||
>
|
||
+ New Client
|
||
</button>
|
||
)}
|
||
{activeTab === 'subcontractors' && (
|
||
<button
|
||
className="btn btn-primary btn-sm"
|
||
onClick={() => { setShowNewUser(true); setShowNew(false); setUserForm(f => ({ ...f, role: 'external', company_id: '' })); }}
|
||
>
|
||
+ New Subcontractor
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{activeTab === 'companies' && (
|
||
<>
|
||
<div className="card request-toolbar-card">
|
||
<div className="request-toolbar-section">
|
||
<div className="card-title" style={{ marginBottom: 10 }}>Restore Archive</div>
|
||
<div className="request-toolbar-actions">
|
||
<button
|
||
className="btn btn-outline btn-sm"
|
||
onClick={() => restoreInputRef.current?.click()}
|
||
disabled={restoringArchive}
|
||
>
|
||
{restoringArchive ? 'Restoring...' : 'Restore Archive'}
|
||
</button>
|
||
</div>
|
||
{restoreStatus && <div className="request-toolbar-status">{restoreStatus}</div>}
|
||
</div>
|
||
</div>
|
||
|
||
{companies.length === 0 ? (
|
||
<div className="empty-state">
|
||
<h3>No companies yet</h3>
|
||
<p>Create a company to get started.</p>
|
||
<button className="btn btn-primary" style={{ marginTop: 16 }} onClick={() => setShowNew(true)}>+ New Company</button>
|
||
</div>
|
||
) : (
|
||
<div className="table-wrapper">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Company</th>
|
||
<th>Clients</th>
|
||
<th>Phone</th>
|
||
<th>Address</th>
|
||
<th style={{ width: 1, whiteSpace: 'nowrap' }}>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{companies.map(company => {
|
||
const companyProfiles = clientProfiles.filter(profile => getProfileCompanyIds(profile).includes(company.id));
|
||
|
||
return (
|
||
<tr key={company.id} onClick={() => navigate(`/companies/${company.id}`)} style={{ cursor: 'pointer' }}>
|
||
<td>
|
||
<div style={{ fontWeight: 600 }}>{company.name}</div>
|
||
{companyProfiles.length > 0 && (
|
||
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||
{companyProfiles.map(profile => (
|
||
<div key={profile.id} style={{ display: 'flex', alignItems: 'flex-start', gap: 8, color: 'var(--text-muted)', fontSize: 12, lineHeight: 1.4 }}>
|
||
<span style={{ color: 'var(--accent)', lineHeight: 1.2 }}>•</span>
|
||
<div style={{ minWidth: 0 }}>
|
||
<div style={{ color: 'var(--text-secondary)', fontWeight: 600 }}>{profile.name || '—'}</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</td>
|
||
<td>{companyProfiles.length}</td>
|
||
<td>{company.phone || '—'}</td>
|
||
<td>{company.address || '—'}</td>
|
||
<td onClick={(e) => e.stopPropagation()}>
|
||
<button
|
||
className="btn btn-outline btn-sm"
|
||
style={{ color: 'var(--danger)', borderColor: 'var(--danger)' }}
|
||
onClick={() => handleDeleteCompany(company)}
|
||
>
|
||
Delete
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{activeTab === 'clients' && (
|
||
<>
|
||
<div className="card request-toolbar-card" style={{ marginBottom: 16 }}>
|
||
{companies.length > 0 && (
|
||
<div className="request-toolbar-grid">
|
||
<div className="request-toolbar-section">
|
||
<div className="card-title" style={{ marginBottom: 10 }}>Filter By Company</div>
|
||
<div className="request-filter-row">
|
||
<button
|
||
className={`btn btn-sm ${!filterCompany ? 'btn-primary' : 'btn-outline'}`}
|
||
onClick={() => setFilterCompany('')}
|
||
>
|
||
All
|
||
</button>
|
||
{companies.map(company => (
|
||
<button
|
||
key={company.id}
|
||
className={`btn btn-sm ${filterCompany === company.id ? 'btn-primary' : 'btn-outline'}`}
|
||
onClick={() => setFilterCompany(current => current === company.id ? '' : company.id)}
|
||
>
|
||
{company.name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{unassigned.length > 0 && (
|
||
<div className="card" style={{ marginBottom: 24, borderColor: 'var(--danger)' }}>
|
||
<div className="card-title" style={{ color: 'var(--danger)' }}>Unassigned Client Users</div>
|
||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 12 }}>
|
||
These client users are not linked to any company yet.
|
||
</p>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||
{unassigned.map(user => (
|
||
<div key={user.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', background: 'var(--bg)', borderRadius: 8, 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: 600, fontSize: 13 }}>{user.name}</div>
|
||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{user.email}</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
{editingUserId !== user.id && (
|
||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||
<span style={{ fontSize: 12, color: 'var(--danger)', fontWeight: 500, marginRight: 4 }}>No company</span>
|
||
<button className="btn btn-outline btn-sm" onClick={() => { setEditingUserId(user.id); setEditUserVal(user.name); }}>Edit</button>
|
||
<button
|
||
className="btn btn-outline btn-sm"
|
||
style={{ color: 'var(--danger)', borderColor: 'var(--danger)' }}
|
||
onClick={() => handleDeleteUser(user)}
|
||
disabled={deletingUserId === user.id}
|
||
>{deletingUserId === user.id ? '...' : 'Delete'}</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{clientProfiles.filter(profile => !filterCompany || getProfileCompanyIds(profile).includes(filterCompany)).length === 0 ? (
|
||
<div className="empty-state">
|
||
<h3>No client users</h3>
|
||
<p>Create a client user to link them to a company.</p>
|
||
</div>
|
||
) : (
|
||
<div className="table-wrapper">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Name</th>
|
||
<th>Email</th>
|
||
<th>Company</th>
|
||
<th>Role</th>
|
||
<th style={{ width: 1, whiteSpace: 'nowrap' }}>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{clientProfiles
|
||
.filter(profile => !filterCompany || getProfileCompanyIds(profile).includes(filterCompany))
|
||
.map(user => {
|
||
const companyNames = getProfileCompanyIds(user)
|
||
.map(companyId => companies.find(company => company.id === companyId)?.name)
|
||
.filter(Boolean);
|
||
|
||
return (
|
||
<tr key={user.id}>
|
||
<td style={{ fontWeight: 600 }}>
|
||
{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>
|
||
) : (
|
||
user.name || '—'
|
||
)}
|
||
</td>
|
||
<td>{user.email || '—'}</td>
|
||
<td>{companyNames.length ? companyNames.join(', ') : '—'}</td>
|
||
<td>{getRoleLabel(user.role)}</td>
|
||
<td>
|
||
{editingUserId !== user.id && (
|
||
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end', flexWrap: 'nowrap', whiteSpace: 'nowrap' }}>
|
||
<button className="btn btn-outline btn-sm" onClick={() => { setEditingUserId(user.id); setEditUserVal(user.name || ''); }}>
|
||
Edit
|
||
</button>
|
||
<button
|
||
className="btn btn-outline btn-sm"
|
||
style={{ color: 'var(--danger)', borderColor: 'var(--danger)' }}
|
||
onClick={() => handleDeleteUser(user)}
|
||
disabled={deletingUserId === user.id}
|
||
>
|
||
{deletingUserId === user.id ? '...' : 'Delete'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{activeTab === 'subcontractors' && (
|
||
<div>
|
||
{subcontractors.length === 0 ? (
|
||
<div className="empty-state" style={{ padding: '20px 18px' }}>
|
||
<h3>No subcontractors yet</h3>
|
||
<p>Create a subcontractor user to manage external access and POs.</p>
|
||
</div>
|
||
) : (
|
||
<div className="table-wrapper">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Name</th>
|
||
<th>Email</th>
|
||
<th>Role</th>
|
||
<th style={{ width: 1, whiteSpace: 'nowrap' }}>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{subcontractors.map(user => (
|
||
<tr key={user.id}>
|
||
<td style={{ fontWeight: 600 }}>
|
||
{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>
|
||
) : (
|
||
user.name || '—'
|
||
)}
|
||
</td>
|
||
<td>{user.email || '—'}</td>
|
||
<td>{getRoleLabel(user.role)}</td>
|
||
<td>
|
||
{editingUserId !== user.id && (
|
||
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end', flexWrap: 'nowrap', whiteSpace: 'nowrap' }}>
|
||
<button className="btn btn-outline btn-sm" onClick={() => { setEditingUserId(user.id); setEditUserVal(user.name || ''); }}>
|
||
Edit
|
||
</button>
|
||
<button
|
||
className="btn btn-outline btn-sm"
|
||
style={{ color: 'var(--danger)', borderColor: 'var(--danger)' }}
|
||
onClick={() => handleDeleteUser(user)}
|
||
disabled={deletingUserId === user.id}
|
||
>
|
||
{deletingUserId === user.id ? '...' : 'Delete'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</Layout>
|
||
);
|
||
}
|