Refactor: clients → companies schema v2
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
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';
|
||||
|
||||
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 [unassigned, setUnassigned] = useState([]);
|
||||
const [prices, setPrices] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tab, setTab] = useState('projects');
|
||||
const [savingPrice, setSavingPrice] = useState(null);
|
||||
const [assigning, setAssigning] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [id]);
|
||||
|
||||
async function load() {
|
||||
const [{ data: co }, { data: p }, { data: pr }, { data: u }, { data: unassignedUsers }] = 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('profiles').select('id, name, email, created_at').eq('company_id', id).eq('role', 'client'),
|
||||
supabase.from('profiles').select('id, name, email').eq('role', 'client').is('company_id', null),
|
||||
]);
|
||||
setCompany(co);
|
||||
const projectList = p || [];
|
||||
setProjects(projectList);
|
||||
setPrices(pr || []);
|
||||
setUsers(u || []);
|
||||
setUnassigned(unassignedUsers || []);
|
||||
|
||||
if (projectList.length > 0) {
|
||||
const { data: t } = await supabase
|
||||
.from('tasks')
|
||||
.select('*')
|
||||
.in('project_id', projectList.map(pr => pr.id));
|
||||
setTasks(t || []);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
const handleAssignUser = async (userId) => {
|
||||
setAssigning(true);
|
||||
await supabase.from('profiles').update({ company_id: id }).eq('id', userId);
|
||||
// Move user from unassigned to users list
|
||||
const user = unassigned.find(u => u.id === userId);
|
||||
if (user) {
|
||||
setUsers(prev => [...prev, { ...user, created_at: new Date().toISOString() }]);
|
||||
setUnassigned(prev => prev.filter(u => u.id !== userId));
|
||||
}
|
||||
setAssigning(false);
|
||||
};
|
||||
|
||||
const handleRemoveUser = async (userId) => {
|
||||
if (!window.confirm('Remove this user from the company? They will lose access to all company data.')) return;
|
||||
await supabase.from('profiles').update({ company_id: null }).eq('id', userId);
|
||||
const user = users.find(u => u.id === userId);
|
||||
if (user) {
|
||||
setUsers(prev => prev.filter(u => u.id !== userId));
|
||||
setUnassigned(prev => [...prev, user]);
|
||||
}
|
||||
};
|
||||
|
||||
const getPrice = (serviceType) => prices.find(p => p.service_type === serviceType)?.price ?? '';
|
||||
|
||||
const handlePriceChange = (serviceType, value) => {
|
||||
setPrices(prev => {
|
||||
const existing = prev.find(p => p.service_type === serviceType);
|
||||
if (existing) return prev.map(p => p.service_type === serviceType ? { ...p, price: value } : p);
|
||||
return [...prev, { service_type: serviceType, price: value, company_id: id }];
|
||||
});
|
||||
};
|
||||
|
||||
const handlePriceSave = async (serviceType) => {
|
||||
setSavingPrice(serviceType);
|
||||
const priceVal = getPrice(serviceType);
|
||||
const existing = prices.find(p => p.service_type === serviceType && p.id);
|
||||
if (existing) {
|
||||
await supabase.from('company_prices').update({ price: Number(priceVal) }).eq('id', existing.id);
|
||||
} else {
|
||||
const { data } = await supabase.from('company_prices').insert({
|
||||
company_id: id, service_type: serviceType, price: Number(priceVal),
|
||||
}).select().single();
|
||||
if (data) setPrices(prev => prev.map(p => p.service_type === serviceType ? data : p));
|
||||
}
|
||||
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>
|
||||
<div className="page-title">{company.name}</div>
|
||||
<div className="page-subtitle">
|
||||
{company.email && <>{company.email}</>}
|
||||
{company.email && company.phone && ' · '}
|
||||
{company.phone && <>{company.phone}</>}
|
||||
{!company.email && !company.phone && 'No contact info'}
|
||||
</div>
|
||||
</div>
|
||||
<span className="badge badge-client" style={{ fontSize: 13, padding: '6px 14px' }}>Company</span>
|
||||
</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 }}>
|
||||
{['projects', 'users', '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' && unassigned.length > 0 && (
|
||||
<span style={{ marginLeft: 6, fontSize: 10, background: 'var(--danger)', color: 'white', padding: '1px 5px', borderRadius: 10, fontWeight: 700 }}>
|
||||
{unassigned.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Projects Tab */}
|
||||
{tab === 'projects' && projects.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<h3>No projects yet</h3>
|
||||
<p>Projects will appear here when requests come in.</p>
|
||||
</div>
|
||||
)}
|
||||
{tab === 'projects' && projects.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{projects.map(project => {
|
||||
const projectTasks = tasks.filter(t => t.project_id === project.id);
|
||||
const active = projectTasks.filter(t => t.status !== 'client_approved');
|
||||
return (
|
||||
<div key={project.id} className="request-card">
|
||||
<div className="request-card-header">
|
||||
<div>
|
||||
<div className="request-card-title">
|
||||
<Link to={`/projects/${project.id}`} className="table-link">{project.name}</Link>
|
||||
</div>
|
||||
{project.description && <div className="request-card-meta">{project.description}</div>}
|
||||
<div className="request-card-meta" style={{ marginTop: 4 }}>
|
||||
Started {new Date(project.created_at).toLocaleDateString()} · {projectTasks.length} job{projectTasks.length !== 1 ? 's' : ''} · {active.length} active
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<StatusBadge status={project.status} />
|
||||
<Link to={`/projects/${project.id}`} className="btn btn-outline btn-sm">View</Link>
|
||||
</div>
|
||||
</div>
|
||||
{projectTasks.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 8 }}>
|
||||
{projectTasks.map(task => (
|
||||
<Link key={task.id} to={`/tasks/${task.id}`} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '5px 12px', borderRadius: 6,
|
||||
background: 'var(--bg)', border: '1px solid var(--border)',
|
||||
fontSize: 12, fontWeight: 500, color: 'var(--text-primary)',
|
||||
textDecoration: 'none',
|
||||
}}>
|
||||
{task.title}
|
||||
<StatusBadge status={task.status} />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</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 }}>
|
||||
<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>
|
||||
<div style={{ fontWeight: 600, fontSize: 14 }}>{user.name}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{user.email || '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-outline btn-sm"
|
||||
style={{ fontSize: 11, color: 'var(--danger)', borderColor: 'var(--danger)' }}
|
||||
onClick={() => handleRemoveUser(user.id)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{unassigned.length > 0 && (
|
||||
<div className="card">
|
||||
<div className="card-title">Unassigned Users</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 14 }}>
|
||||
These users have signed up but aren't assigned 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>
|
||||
<div style={{ fontWeight: 600, fontSize: 13 }}>{user.name}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{user.email}</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => handleAssignUser(user.id)}
|
||||
disabled={assigning}
|
||||
>
|
||||
Assign to {company.name}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</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: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{serviceTypes.map(serviceType => (
|
||||
<div key={serviceType} style={{ display: 'grid', gridTemplateColumns: '1fr 160px 80px', gap: 10, alignItems: 'center' }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 500 }}>{serviceType}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ color: 'var(--text-muted)', fontSize: 14 }}>$</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
value={getPrice(serviceType)}
|
||||
onChange={e => handlePriceChange(serviceType, e.target.value)}
|
||||
style={{ margin: 0 }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={() => handlePriceSave(serviceType)}
|
||||
disabled={savingPrice === serviceType}
|
||||
>
|
||||
{savingPrice === serviceType ? '...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user