Fix file sharing load speed and move error; misc updates
- 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>
This commit is contained in:
+472
-150
@@ -1,16 +1,26 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { cleanupTaskStorage } from '../../lib/deleteHelpers';
|
||||
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 [companies, setCompanies] = useState([]);
|
||||
const [profiles, setProfiles] = useState([]);
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
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);
|
||||
@@ -20,25 +30,29 @@ export default function Companies() {
|
||||
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();
|
||||
}, []);
|
||||
|
||||
async function load() {
|
||||
const [{ data: co }, { data: prof }, { data: p }, { data: t }] = await Promise.all([
|
||||
supabase.from('companies').select('*').order('name'),
|
||||
supabase.from('profiles').select('id, name, email, company_id').eq('role', 'client'),
|
||||
supabase.from('projects').select('id, company_id, status'),
|
||||
supabase.from('tasks').select('id, project_id, status'),
|
||||
]);
|
||||
setCompanies(co || []);
|
||||
setProfiles(prof || []);
|
||||
setProjects(p || []);
|
||||
setTasks(t || []);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
const handleCreate = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!newForm.name.trim()) return;
|
||||
@@ -50,6 +64,7 @@ export default function Companies() {
|
||||
}).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}`);
|
||||
@@ -58,16 +73,39 @@ export default function Companies() {
|
||||
|
||||
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;
|
||||
|
||||
const companyProjects = projects.filter(p => p.company_id === company.id);
|
||||
const projectIds = companyProjects.map(p => p.id);
|
||||
if (projectIds.length) {
|
||||
const companyTaskIds = tasks.filter(t => projectIds.includes(t.project_id)).map(t => t.id);
|
||||
await cleanupTaskStorage(companyTaskIds);
|
||||
}
|
||||
|
||||
await supabase.from('companies').delete().eq('id', company.id);
|
||||
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) => {
|
||||
@@ -81,7 +119,8 @@ export default function Companies() {
|
||||
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 errMsg = data?.error || error?.message;
|
||||
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);
|
||||
@@ -101,19 +140,33 @@ export default function Companies() {
|
||||
},
|
||||
});
|
||||
setSaving(false);
|
||||
const errMsg = data?.error || error?.message || (error ? JSON.stringify(error) : null);
|
||||
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 unassigned = profiles.filter(p => !p.company_id);
|
||||
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>
|
||||
@@ -122,20 +175,48 @@ export default function Companies() {
|
||||
<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 user{unassigned.length !== 1 ? 's' : ''}
|
||||
· {unassigned.length} unassigned client{unassigned.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn btn-outline btn-sm" onClick={() => { setShowNewUser(s => !s); setShowNew(false); setUserError(''); }}>
|
||||
{showNewUser ? 'Cancel' : '+ New User'}
|
||||
</button>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => { setShowNew(s => !s); setShowNewUser(false); }}>
|
||||
{showNew ? 'Cancel' : '+ New Company'}
|
||||
</button>
|
||||
<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>
|
||||
|
||||
@@ -166,7 +247,7 @@ export default function Companies() {
|
||||
<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">External</option>
|
||||
<option value="external">Subcontractor</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -235,121 +316,362 @@ export default function Companies() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{unassigned.length > 0 && (
|
||||
<div className="card" style={{ marginBottom: 24, borderColor: 'var(--danger)' }}>
|
||||
<div className="card-title" style={{ color: 'var(--danger)' }}>Unassigned Users</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 12 }}>
|
||||
These users have signed up but haven't been assigned to a 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 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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{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 style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{companies.map(company => {
|
||||
const companyProfiles = profiles.filter(p => p.company_id === company.id);
|
||||
const companyProjects = projects.filter(p => p.company_id === company.id);
|
||||
const projectIds = companyProjects.map(p => p.id);
|
||||
const activeTasks = tasks.filter(t => projectIds.includes(t.project_id) && t.status !== 'client_approved');
|
||||
|
||||
return (
|
||||
<div key={company.id} style={{ border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden', background: 'var(--card-bg)' }}>
|
||||
<Link to={`/companies/${company.id}`} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 18px', background: 'var(--card-bg-2)', borderBottom: '1px solid var(--border)', textDecoration: 'none', cursor: 'pointer' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--text-primary)' }}>{company.name}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 3 }}>
|
||||
{companyProfiles.length} user{companyProfiles.length !== 1 ? 's' : ''}
|
||||
{' · '}
|
||||
{companyProjects.length} project{companyProjects.length !== 1 ? 's' : ''}
|
||||
{activeTasks.length > 0 && <> · <span style={{ color: 'var(--accent)', fontWeight: 600 }}>{activeTasks.length} active</span></>}
|
||||
{company.phone && <> · {company.phone}</>}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>›</span>
|
||||
{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
|
||||
type="button"
|
||||
onClick={e => { e.preventDefault(); e.stopPropagation(); handleDeleteCompany(company); }}
|
||||
style={{ background: 'none', border: 'none', color: 'var(--danger, #dc2626)', cursor: 'pointer', fontSize: 16, padding: '0 4px', lineHeight: 1 }}
|
||||
title="Delete company"
|
||||
>✕</button>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{companyProfiles.length > 0 && (
|
||||
<div>
|
||||
{companyProfiles.map((profile, i) => (
|
||||
<div
|
||||
key={profile.id}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '10px 18px',
|
||||
borderBottom: i < companyProfiles.length - 1 ? '1px solid var(--border)' : 'none',
|
||||
}}
|
||||
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)}
|
||||
>
|
||||
<div style={{
|
||||
width: 28, height: 28, borderRadius: 4, background: 'var(--accent)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 11, fontWeight: 700, color: '#111', flexShrink: 0,
|
||||
}}>
|
||||
{profile.name?.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{profile.name}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{profile.email || '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
{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>
|
||||
|
||||
Reference in New Issue
Block a user