Files
fourge-portal/src/pages/team/CompanyDetail.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

535 lines
26 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { serviceTypes } from '../../data/mockData';
import { cleanupTaskStorage, deleteCompanyData } from '../../lib/deleteHelpers';
import { syncSeafileFolders } from '../../lib/seafileFolders';
export default function CompanyDetail() {
const { id } = useParams();
const navigate = useNavigate();
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);
await supabase.from('companies').update({ name: nameVal.trim() }).eq('id', id);
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));
syncSeafileFolders().catch((syncError) => console.warn('Seafile folder sync failed:', syncError.message));
}
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('/companies');
};
const handleDeleteProject = async (project) => {
if (!window.confirm(`Delete project "${project.name}"? All jobs and files in this project will be permanently deleted.`)) return;
const projectTaskIds = tasks.filter(t => t.project_id === project.id).map(t => t.id);
await cleanupTaskStorage(projectTaskIds);
await supabase.from('projects').delete().eq('id', project.id);
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) {
setProjects(prev => [data, ...prev]);
setNewProjectName('');
setShowNewProject(false);
}
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('/companies')}> Back to Companies</button>
<div className="page-header">
<div>
{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: 700, 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>
<button className="btn btn-outline btn-sm" onClick={() => { setNameVal(company.name); setEditingName(true); }}>Edit</button>
</div>
)}
<div className="page-subtitle">
{company.phone && <>{company.phone}</>}
{company.phone && company.address && ' · '}
{company.address && <>{company.address}</>}
{!company.phone && !company.address && 'No contact info'}
</div>
</div>
<button
className="btn btn-outline btn-sm"
style={{ color: 'var(--danger, #dc2626)', borderColor: 'var(--danger, #dc2626)' }}
onClick={handleDeleteCompany}
>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, borderBottom: '1px solid var(--border)', paddingBottom: 0 }}>
{['users', 'projects', 'pricing'].map(t => (
<button
key={t}
onClick={() => setTab(t)}
style={{
background: 'none', border: 'none', cursor: 'pointer',
padding: '8px 16px', fontSize: 13, fontWeight: 600,
color: tab === t ? 'var(--accent)' : 'var(--text-muted)',
borderBottom: tab === t ? '2px solid var(--accent)' : '2px solid transparent',
marginBottom: -1, textTransform: 'capitalize', fontFamily: 'inherit',
}}
>
{t}
{t === 'users' && availableUsers.length > 0 && (
<span style={{ marginLeft: 6, fontSize: 10, background: 'var(--danger)', color: 'white', padding: '1px 5px', borderRadius: 10, fontWeight: 700 }}>
{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: 700, 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: 600, 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>
{editingUserId !== user.id && (
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
<button
className="btn btn-outline btn-sm"
onClick={() => { setEditingUserId(user.id); setEditUserVal(user.name); }}
>Edit</button>
<button
className="btn btn-outline btn-sm"
onClick={() => handleRemoveUser(user.id)}
>Unassign</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>
{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: 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 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 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>
)}
</div>
)}
{/* Projects Tab */}
{tab === 'projects' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<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: 8, 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: 600, 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>
<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: 600, 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>
);
}