Files
fourge-portal/src/pages/team/Companies.jsx
T
Krao Hasanee eee0885811 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>
2026-05-13 14:20:38 -04:00

680 lines
31 KiB
React
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef } from 'react';
import { 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>
);
}