Refactor: clients → companies schema v2

This commit is contained in:
Krao Hasanee
2026-03-26 23:42:06 -04:00
commit 719209fa25
61 changed files with 8192 additions and 0 deletions
+206
View File
@@ -0,0 +1,206 @@
import { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout';
import { supabase } from '../../lib/supabase';
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 [showNew, setShowNew] = useState(false);
const [newForm, setNewForm] = useState({ name: '', email: '', phone: '' });
const [saving, setSaving] = useState(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;
setSaving(true);
const { data } = await supabase.from('companies').insert({
name: newForm.name.trim(),
email: newForm.email.trim(),
phone: newForm.phone.trim(),
}).select().single();
setSaving(false);
if (data) {
setShowNew(false);
setNewForm({ name: '', email: '', phone: '' });
navigate(`/companies/${data.id}`);
}
};
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
const unassigned = profiles.filter(p => !p.company_id);
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">Companies</div>
<div className="page-subtitle">
{companies.length} company{companies.length !== 1 ? 'ies' : ''}
{unassigned.length > 0 && (
<span style={{ marginLeft: 10, color: 'var(--danger)', fontWeight: 600 }}>
· {unassigned.length} unassigned user{unassigned.length !== 1 ? 's' : ''}
</span>
)}
</div>
</div>
<button className="btn btn-primary" onClick={() => setShowNew(s => !s)}>
{showNew ? 'Cancel' : '+ New Company'}
</button>
</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>Email</label>
<input
type="email"
placeholder="contact@acme.com"
value={newForm.email}
onChange={e => setNewForm(f => ({ ...f, email: e.target.value }))}
/>
</div>
<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>
<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>
)}
{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>
<div style={{ fontWeight: 600, fontSize: 13 }}>{user.name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{user.email}</div>
</div>
<span style={{ fontSize: 12, color: 'var(--danger)', fontWeight: 500 }}>No company</span>
</div>
))}
</div>
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 10 }}>
Open a company and assign them from the Users tab.
</p>
</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)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 18px', background: 'var(--card-bg-2)', borderBottom: '1px solid var(--border)' }}>
<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.email && <> · {company.email}</>}
</div>
</div>
<Link to={`/companies/${company.id}`} className="btn btn-outline btn-sm">View</Link>
</div>
{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',
}}
>
<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>
))}
</div>
)}
</div>
);
})}
</div>
)}
</Layout>
);
}
+326
View File
@@ -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>
);
}
+236
View File
@@ -0,0 +1,236 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout';
import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext';
function newItem(description = '', unit_price = '', quantity = 1, task_id = null) {
return { id: crypto.randomUUID(), description, unit_price, quantity, task_id };
}
export default function CreateInvoice() {
const navigate = useNavigate();
const { currentUser } = useAuth();
const [companies, setCompanies] = useState([]);
const [selectedCompanyId, setSelectedCompanyId] = useState('');
const [uninvoicedTasks, setUninvoicedTasks] = useState([]);
const [priceList, setPriceList] = useState([]);
const [items, setItems] = useState([newItem()]);
const [notes, setNotes] = useState('');
const [saving, setSaving] = useState(false);
const [loadingTasks, setLoadingTasks] = useState(false);
const today = new Date().toISOString().split('T')[0];
const net30 = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
useEffect(() => {
supabase.from('companies').select('id, name, email').order('name').then(({ data }) => setCompanies(data || []));
}, []);
useEffect(() => {
if (!selectedCompanyId) { setUninvoicedTasks([]); setPriceList([]); setItems([newItem()]); return; }
setLoadingTasks(true);
Promise.all([
supabase.from('projects').select('id').eq('company_id', selectedCompanyId),
supabase.from('company_prices').select('*').eq('company_id', selectedCompanyId),
]).then(async ([{ data: projects }, { data: prices }]) => {
setPriceList(prices || []);
if (projects && projects.length > 0) {
const { data: tasks } = await supabase
.from('tasks')
.select('*, project:projects(name)')
.in('project_id', projects.map(p => p.id))
.eq('invoiced', false);
setUninvoicedTasks(tasks || []);
} else {
setUninvoicedTasks([]);
}
setLoadingTasks(false);
});
}, [selectedCompanyId]);
const addTaskAsItem = (task) => {
const price = priceList.find(p => p.service_type === task.title);
setItems(prev => {
if (prev.length === 1 && !prev[0].description && !prev[0].unit_price) {
return [newItem(task.title, price?.price || '', 1, task.id)];
}
return [...prev, newItem(task.title, price?.price || '', 1, task.id)];
});
};
const updateItem = (id, field, value) => {
setItems(prev => prev.map(item => item.id === id ? { ...item, [field]: value } : item));
};
const removeItem = (id) => setItems(prev => prev.filter(item => item.id !== id));
const total = items.reduce((sum, item) => sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0), 0);
const handleSave = async (status) => {
if (!selectedCompanyId) return alert('Please select a company.');
if (items.every(i => !i.description)) return alert('Please add at least one line item.');
setSaving(true);
const year = new Date().getFullYear();
const { count } = await supabase.from('invoices').select('*', { count: 'exact', head: true }).gte('created_at', `${year}-01-01`);
const invoiceNumber = `INV-${year}-${String((count || 0) + 1).padStart(3, '0')}`;
const { data: invoice, error } = await supabase.from('invoices').insert({
company_id: selectedCompanyId,
invoice_number: invoiceNumber,
invoice_date: today,
due_date: net30,
status,
notes: notes || null,
total,
created_by: currentUser?.id,
}).select().single();
if (error || !invoice) { setSaving(false); alert('Error saving invoice.'); return; }
const validItems = items.filter(i => i.description);
if (validItems.length > 0) {
await supabase.from('invoice_items').insert(
validItems.map(item => ({
invoice_id: invoice.id,
task_id: item.task_id || null,
description: item.description,
quantity: Number(item.quantity) || 1,
unit_price: Number(item.unit_price) || 0,
}))
);
}
const taskIds = validItems.filter(i => i.task_id).map(i => i.task_id);
if (taskIds.length > 0) {
await supabase.from('tasks').update({ invoiced: true }).in('id', taskIds);
}
navigate(`/invoices/${invoice.id}`);
};
const selectedCompany = companies.find(c => c.id === selectedCompanyId);
return (
<Layout>
<button className="back-link" onClick={() => navigate('/invoices')}> Back to Invoices</button>
<div className="page-header">
<div>
<div className="page-title">New Invoice</div>
<div className="page-subtitle">Invoice date: {new Date(today).toLocaleDateString()} · Due: {new Date(net30).toLocaleDateString()} (Net 30)</div>
</div>
<div className="action-buttons">
<button className="btn btn-outline" onClick={() => handleSave('draft')} disabled={saving}>Save Draft</button>
<button className="btn btn-primary" onClick={() => handleSave('sent')} disabled={saving}>
{saving ? 'Saving...' : 'Finalise & Send'}
</button>
</div>
</div>
<div className="grid-2" style={{ gap: 24, marginBottom: 24 }}>
<div className="card">
<div className="card-title">Company</div>
<div className="form-group">
<label>Select Company *</label>
<select value={selectedCompanyId} onChange={e => setSelectedCompanyId(e.target.value)}>
<option value="">Choose a company...</option>
{companies.map(c => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
{selectedCompany && (
<div style={{ padding: '12px 14px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)', fontSize: 13 }}>
<div style={{ fontWeight: 600 }}>{selectedCompany.name}</div>
{selectedCompany.email && <div style={{ color: 'var(--text-muted)', marginTop: 2 }}>{selectedCompany.email}</div>}
</div>
)}
</div>
<div className="card">
<div className="card-title">Uninvoiced Requests</div>
{!selectedCompanyId ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>Select a company to see their uninvoiced requests.</p>
) : loadingTasks ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading...</p>
) : uninvoicedTasks.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>No uninvoiced requests found.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{uninvoicedTasks.map(task => {
const price = priceList.find(p => p.service_type === task.title);
const alreadyAdded = items.some(i => i.task_id === task.id);
return (
<div key={task.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 6, border: '1px solid var(--border)' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 600 }}>{task.title}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{task.project?.name} · {price ? `$${Number(price.price).toFixed(2)}` : 'No price set'}</div>
</div>
<button
className={`btn btn-sm ${alreadyAdded ? 'btn-outline' : 'btn-primary'}`}
onClick={() => !alreadyAdded && addTaskAsItem(task)}
disabled={alreadyAdded}
>
{alreadyAdded ? 'Added' : '+ Add'}
</button>
</div>
);
})}
</div>
)}
</div>
</div>
<div className="card" style={{ marginBottom: 24 }}>
<div className="card-title">Line Items</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 80px 120px 120px 40px', gap: 8, marginBottom: 8 }}>
{['Description', 'Qty', 'Unit Price', 'Total', ''].map((h, i) => (
<div key={i} style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', textAlign: i > 1 ? 'right' : 'left' }}>{h}</div>
))}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{items.map(item => (
<div key={item.id} style={{ display: 'grid', gridTemplateColumns: '1fr 80px 120px 120px 40px', gap: 8, alignItems: 'center' }}>
<input type="text" placeholder="Description..." value={item.description} onChange={e => updateItem(item.id, 'description', e.target.value)} style={{ margin: 0 }} />
<input type="number" min="1" value={item.quantity} onChange={e => updateItem(item.id, 'quantity', e.target.value)} style={{ margin: 0, textAlign: 'center' }} />
<input type="number" min="0" step="0.01" placeholder="0.00" value={item.unit_price} onChange={e => updateItem(item.id, 'unit_price', e.target.value)} style={{ margin: 0, textAlign: 'right' }} />
<div style={{ textAlign: 'right', fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', paddingRight: 4 }}>
${((Number(item.quantity) || 0) * (Number(item.unit_price) || 0)).toFixed(2)}
</div>
<button onClick={() => removeItem(item.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--danger)', fontSize: 16, padding: 4 }}></button>
</div>
))}
</div>
<button className="btn btn-outline btn-sm" style={{ marginTop: 12 }} onClick={() => setItems(prev => [...prev, newItem()])}>
+ Add Line Item
</button>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 20, paddingTop: 16, borderTop: '1px solid var(--border)' }}>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>Total</div>
<div style={{ fontSize: 26, fontWeight: 700, color: 'var(--accent)' }}>${total.toFixed(2)}</div>
</div>
</div>
</div>
<div className="card" style={{ marginBottom: 24 }}>
<div className="card-title">Notes</div>
<textarea placeholder="Additional notes, payment instructions, or terms..." value={notes} onChange={e => setNotes(e.target.value)} style={{ minHeight: 80 }} />
</div>
<div className="action-buttons">
<button className="btn btn-outline" onClick={() => handleSave('draft')} disabled={saving}>Save Draft</button>
<button className="btn btn-primary" onClick={() => handleSave('sent')} disabled={saving}>
{saving ? 'Saving...' : 'Finalise & Send'}
</button>
<button className="btn btn-outline" onClick={() => navigate('/invoices')}>Cancel</button>
</div>
</Layout>
);
}
+174
View File
@@ -0,0 +1,174 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import Layout from '../../components/Layout';
import StatusBadge from '../../components/StatusBadge';
import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext';
function CompanyGroup({ company, tasks, projects }) {
const [open, setOpen] = useState(true);
return (
<div style={{ marginBottom: 10, border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden', background: 'var(--card-bg)' }}>
<button
onClick={() => setOpen(o => !o)}
style={{
width: '100%', display: 'flex', alignItems: 'center',
justifyContent: 'space-between', padding: '10px 14px',
background: 'var(--card-bg-2)', border: 'none', cursor: 'pointer',
borderBottom: open ? '1px solid var(--border)' : 'none',
fontFamily: 'inherit',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)' }}>
{company.name}
</span>
<span style={{ fontSize: 11, fontWeight: 600, padding: '1px 7px', borderRadius: 20, background: 'var(--accent)', color: '#1a1a1a' }}>
{tasks.length}
</span>
</div>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{open ? '▲' : '▼'}</span>
</button>
{open && (
<div>
{tasks.map(task => {
const project = projects.find(p => p.id === task.project_id);
return (
<div key={task.id} style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)', display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Link to={`/tasks/${task.id}`} className="table-link" style={{ fontSize: 13 }}>
{task.title} <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>{'v' + String(task.current_version).padStart(2, '0')}</span>
</Link>
<StatusBadge status={task.status} />
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 11, color: 'var(--text-secondary)' }}>{project?.name}</span>
<span style={{ fontSize: 11, color: task.assigned_name ? 'var(--text-secondary)' : 'var(--text-muted)' }}>
{task.assigned_name || 'Unassigned'}
</span>
</div>
</div>
);
})}
</div>
)}
</div>
);
}
function GroupedColumn({ tasks, companies, projects, emptyText }) {
if (tasks.length === 0) return (
<div style={{ padding: '24px 16px', textAlign: 'center', color: 'var(--text-muted)', fontSize: 13, background: 'var(--card-bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
{emptyText}
</div>
);
const groups = companies
.map(company => {
const companyProjectIds = projects.filter(p => p.company_id === company.id).map(p => p.id);
const companyTasks = tasks.filter(t => companyProjectIds.includes(t.project_id));
return { company, tasks: companyTasks };
})
.filter(g => g.tasks.length > 0);
return (
<div>
{groups.map(({ company, tasks: groupTasks }) => (
<CompanyGroup key={company.id} company={company} tasks={groupTasks} projects={projects} />
))}
</div>
);
}
export default function Dashboard() {
const { currentUser } = useAuth();
const [tasks, setTasks] = useState([]);
const [projects, setProjects] = useState([]);
const [companies, setCompanies] = useState([]);
const [loading, setLoading] = useState(true);
const [showCompleted, setShowCompleted] = useState(false);
useEffect(() => {
async function load() {
const [{ data: t }, { data: p }, { data: co }] = await Promise.all([
supabase.from('tasks').select('*').order('submitted_at', { ascending: false }),
supabase.from('projects').select('*'),
supabase.from('companies').select('*').order('name'),
]);
setTasks(t || []);
setProjects(p || []);
setCompanies(co || []);
setLoading(false);
}
load();
}, []);
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
const activeTasks = tasks.filter(t => ['in_progress', 'on_hold', 'client_review'].includes(t.status));
const notStartedTasks = tasks.filter(t => t.status === 'not_started');
const completedTasks = tasks.filter(t => t.status === 'client_approved');
const activeProjects = projects.filter(p => p.status === 'active');
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">Welcome back, {currentUser?.name?.split(' ')[0]}</div>
<div className="page-subtitle">Here's what's happening across your projects.</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-outline" onClick={() => setShowCompleted(s => !s)}>
{showCompleted ? 'Hide Completed' : 'Show Completed'}
</button>
<Link to="/requests" className="btn btn-primary">View Requests</Link>
</div>
</div>
<div className="stats-grid">
<div className="stat-card">
<div className="stat-icon">📁</div>
<div className="stat-value">{activeProjects.length}</div>
<div className="stat-label">Active 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">{notStartedTasks.length}</div>
<div className="stat-label">Not Started</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>
<div style={{ display: 'grid', gridTemplateColumns: showCompleted ? '1fr 1fr 1fr' : '1fr 1fr', gap: 24 }}>
<div>
<div className="card-title">Not Started</div>
<GroupedColumn tasks={notStartedTasks} companies={companies} projects={projects} emptyText="Nothing waiting to start" />
</div>
<div>
<div className="card-title">Active Jobs</div>
<GroupedColumn tasks={activeTasks} companies={companies} projects={projects} emptyText="No active jobs" />
</div>
{showCompleted && (
<div>
<div className="card-title" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
Completed
<button onClick={() => setShowCompleted(false)} style={{ fontSize: 11, color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer' }}>
Hide
</button>
</div>
<GroupedColumn tasks={completedTasks} companies={companies} projects={projects} emptyText="No completed jobs yet" />
</div>
)}
</div>
</Layout>
);
}
+166
View File
@@ -0,0 +1,166 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import Layout from '../../components/Layout';
import { supabase } from '../../lib/supabase';
import { generateInvoicePDF } from '../../lib/invoice';
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
export default function InvoiceDetail() {
const { id } = useParams();
const navigate = useNavigate();
const [invoice, setInvoice] = useState(null);
const [company, setCompany] = useState(null);
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
async function load() {
const { data: inv } = await supabase.from('invoices').select('*').eq('id', id).single();
if (!inv) { setLoading(false); return; }
setInvoice(inv);
const [{ data: co }, { data: its }] = await Promise.all([
supabase.from('companies').select('*').eq('id', inv.company_id).single(),
supabase.from('invoice_items').select('*').eq('invoice_id', id).order('created_at'),
]);
setCompany(co);
setItems(its || []);
setLoading(false);
}
load();
}, [id]);
const updateStatus = async (status) => {
setSaving(true);
await supabase.from('invoices').update({ status }).eq('id', id);
setInvoice(i => ({ ...i, status }));
setSaving(false);
};
const handleDelete = async () => {
if (!window.confirm('Delete this invoice? This cannot be undone.')) return;
setSaving(true);
try {
const { data: freshItems } = await supabase.from('invoice_items').select('task_id').eq('invoice_id', id);
const taskIds = (freshItems || []).filter(i => i.task_id).map(i => i.task_id);
if (taskIds.length > 0) await supabase.from('tasks').update({ invoiced: false }).in('id', taskIds);
const { error } = await supabase.from('invoices').delete().eq('id', id);
if (error) throw error;
navigate('/invoices');
} catch {
alert('Failed to delete invoice. Please try again.');
setSaving(false);
}
};
const handleDownload = async () => {
await generateInvoicePDF(invoice, company, items);
};
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
if (!invoice) return <Layout><p>Invoice not found.</p></Layout>;
const isOverdue = invoice.status !== 'paid' && new Date(invoice.due_date) < new Date();
return (
<Layout>
<button className="back-link" onClick={() => navigate('/invoices')}> Back to Invoices</button>
<div className="page-header">
<div>
<div className="page-title">{invoice.invoice_number}</div>
<div className="page-subtitle">
<Link to={`/companies/${company?.id}`} style={{ color: 'var(--accent)' }}>{company?.name}</Link>
</div>
</div>
<div className="action-buttons">
<span className={`badge badge-${statusColor[invoice.status]}`} style={{ fontSize: 13, padding: '6px 14px', textTransform: 'capitalize' }}>
{invoice.status}{isOverdue ? ' · Overdue' : ''}
</span>
<button className="btn btn-primary" onClick={handleDownload}>Download PDF</button>
</div>
</div>
<div className="grid-2" style={{ marginBottom: 24 }}>
<div className="card">
<div className="card-title">Bill To</div>
<div style={{ fontSize: 15, fontWeight: 700 }}>{company?.name}</div>
{company?.email && <div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 2 }}>{company.email}</div>}
<div style={{ marginTop: 12 }}>
<Link to={`/companies/${company?.id}`} className="btn btn-outline btn-sm">View Company</Link>
</div>
</div>
<div className="card">
<div className="card-title">Invoice Details</div>
<div className="detail-grid" style={{ marginBottom: 0 }}>
<div className="detail-item"><label>Invoice Date</label><p>{new Date(invoice.invoice_date).toLocaleDateString()}</p></div>
<div className="detail-item"><label>Due Date</label>
<p style={{ color: isOverdue ? 'var(--danger)' : 'inherit' }}>{new Date(invoice.due_date).toLocaleDateString()}</p>
</div>
<div className="detail-item"><label>Terms</label><p>Net 30</p></div>
<div className="detail-item"><label>Total</label><p style={{ fontSize: 18, fontWeight: 700, color: 'var(--accent)' }}>${Number(invoice.total).toFixed(2)}</p></div>
</div>
</div>
</div>
<div className="card" style={{ marginBottom: 24 }}>
<div className="card-title">Line Items</div>
<div className="table-wrapper" style={{ border: 'none' }}>
<table>
<thead>
<tr>
<th>Description</th>
<th style={{ textAlign: 'center' }}>Qty</th>
<th style={{ textAlign: 'right' }}>Unit Price</th>
<th style={{ textAlign: 'right' }}>Total</th>
</tr>
</thead>
<tbody>
{items.map(item => (
<tr key={item.id}>
<td>{item.description}</td>
<td style={{ textAlign: 'center' }}>{item.quantity}</td>
<td style={{ textAlign: 'right' }}>${Number(item.unit_price).toFixed(2)}</td>
<td style={{ textAlign: 'right', fontWeight: 700 }}>${(Number(item.quantity) * Number(item.unit_price)).toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '16px 16px 0', borderTop: '1px solid var(--border)', marginTop: 8 }}>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>Total</div>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--accent)' }}>${Number(invoice.total).toFixed(2)}</div>
</div>
</div>
</div>
{invoice.notes && (
<div className="card" style={{ marginBottom: 24 }}>
<div className="card-title">Notes</div>
<p style={{ fontSize: 14, color: 'var(--text-secondary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{invoice.notes}</p>
</div>
)}
<div className="card">
<div className="card-title">Actions</div>
<div className="action-buttons">
{invoice.status === 'draft' && (
<button className="btn btn-primary" onClick={() => updateStatus('sent')} disabled={saving}>Mark as Sent</button>
)}
{invoice.status === 'sent' && (
<button className="btn btn-success" onClick={() => updateStatus('paid')} disabled={saving}>Mark as Paid</button>
)}
{invoice.status === 'paid' && (
<button className="btn btn-outline" onClick={() => updateStatus('sent')} disabled={saving}>Reopen</button>
)}
<button className="btn btn-primary" onClick={handleDownload}>Download PDF</button>
<button className="btn btn-danger" onClick={handleDelete} disabled={saving}>Delete Invoice</button>
</div>
</div>
</Layout>
);
}
+121
View File
@@ -0,0 +1,121 @@
import { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout';
import { supabase } from '../../lib/supabase';
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
export default function Invoices() {
const navigate = useNavigate();
const [invoices, setInvoices] = useState([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState('all');
useEffect(() => {
async function load() {
const { data } = await supabase
.from('invoices')
.select('*, company:companies(name, email)')
.order('created_at', { ascending: false });
setInvoices(data || []);
setLoading(false);
}
load();
}, []);
const filtered = filter === 'all' ? invoices : invoices.filter(inv => inv.status === filter);
const totals = {
all: invoices.reduce((s, i) => s + Number(i.total), 0),
draft: invoices.filter(i => i.status === 'draft').reduce((s, i) => s + Number(i.total), 0),
sent: invoices.filter(i => i.status === 'sent').reduce((s, i) => s + Number(i.total), 0),
paid: invoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total), 0),
};
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">Invoices</div>
<div className="page-subtitle">{invoices.length} invoice{invoices.length !== 1 ? 's' : ''} total</div>
</div>
<button className="btn btn-primary" onClick={() => navigate('/invoices/new')}>+ New Invoice</button>
</div>
<div className="stats-grid" style={{ marginBottom: 24 }}>
{[
{ label: 'Outstanding', value: totals.sent, count: invoices.filter(i => i.status === 'sent').length },
{ label: 'Paid', value: totals.paid, count: invoices.filter(i => i.status === 'paid').length },
{ label: 'Draft', value: totals.draft, count: invoices.filter(i => i.status === 'draft').length },
{ label: 'Total Billed', value: totals.all, count: invoices.length },
].map(({ label, value, count }) => (
<div key={label} className="stat-card">
<div className="stat-value" style={{ fontSize: 22 }}>${value.toFixed(2)}</div>
<div className="stat-label">{label} · {count} invoice{count !== 1 ? 's' : ''}</div>
</div>
))}
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
{['all', 'draft', 'sent', 'paid'].map(s => (
<button
key={s}
onClick={() => setFilter(s)}
className={`btn btn-sm ${filter === s ? 'btn-primary' : 'btn-outline'}`}
style={{ textTransform: 'capitalize' }}
>
{s}
</button>
))}
</div>
{loading ? (
<p style={{ color: 'var(--text-muted)' }}>Loading...</p>
) : filtered.length === 0 ? (
<div className="empty-state">
<h3>No invoices</h3>
<p>Create your first invoice to get started.</p>
<button className="btn btn-primary" style={{ marginTop: 16 }} onClick={() => navigate('/invoices/new')}>+ New Invoice</button>
</div>
) : (
<div className="table-wrapper">
<table>
<thead>
<tr>
<th>Invoice #</th>
<th>Company</th>
<th>Date</th>
<th>Due</th>
<th>Status</th>
<th>Total</th>
<th></th>
</tr>
</thead>
<tbody>
{filtered.map(inv => (
<tr key={inv.id}>
<td><Link to={`/invoices/${inv.id}`} className="table-link">{inv.invoice_number}</Link></td>
<td>
<div style={{ fontWeight: 600 }}>{inv.company?.name}</div>
{inv.company?.email && <div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{inv.company.email}</div>}
</td>
<td>{new Date(inv.invoice_date).toLocaleDateString()}</td>
<td>
<span style={{ color: inv.status !== 'paid' && new Date(inv.due_date) < new Date() ? 'var(--danger)' : 'inherit' }}>
{new Date(inv.due_date).toLocaleDateString()}
</span>
</td>
<td><span className={`badge badge-${statusColor[inv.status]}`} style={{ textTransform: 'capitalize' }}>{inv.status}</span></td>
<td style={{ fontWeight: 700 }}>${Number(inv.total).toFixed(2)}</td>
<td>
<Link to={`/invoices/${inv.id}`} className="btn btn-outline btn-sm">View</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Layout>
);
}
+125
View File
@@ -0,0 +1,125 @@
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';
export default function ProjectDetail() {
const { id } = useParams();
const navigate = useNavigate();
const [project, setProject] = useState(null);
const [company, setCompany] = useState(null);
const [tasks, setTasks] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function load() {
const { data: p } = await supabase.from('projects').select('*').eq('id', id).single();
if (!p) { setLoading(false); return; }
setProject(p);
const [{ data: co }, { data: t }] = await Promise.all([
supabase.from('companies').select('*').eq('id', p.company_id).single(),
supabase.from('tasks').select('*').eq('project_id', id).order('submitted_at', { ascending: false }),
]);
setCompany(co);
setTasks(t || []);
setLoading(false);
}
load();
}, [id]);
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
if (!project) return <Layout><p>Project not found.</p></Layout>;
return (
<Layout>
<button className="back-link" onClick={() => navigate(`/companies/${company?.id}`)}>
Back to {company?.name}
</button>
<div className="page-header">
<div>
<div className="page-title">{project.name}</div>
<div className="page-subtitle">
<Link to={`/companies/${company?.id}`} style={{ color: 'var(--accent)' }}>
{company?.name}
</Link>
{' · '}Started {new Date(project.created_at).toLocaleDateString()}
</div>
</div>
<StatusBadge status={project.status} />
</div>
<div className="grid-2" style={{ marginBottom: 24 }}>
<div className="card">
<div className="card-title">Project Info</div>
<div className="detail-grid" style={{ marginBottom: 0 }}>
<div className="detail-item"><label>Company</label><p>{company?.name || '—'}</p></div>
<div className="detail-item"><label>Contact</label><p>{company?.email || '—'}</p></div>
<div className="detail-item"><label>Status</label><p><StatusBadge status={project.status} /></p></div>
<div className="detail-item"><label>Started</label><p>{new Date(project.created_at).toLocaleDateString()}</p></div>
</div>
</div>
<div className="card">
<div className="card-title">Project Summary</div>
<div className="detail-grid" style={{ marginBottom: 0 }}>
<div className="detail-item"><label>Total Jobs</label><p>{tasks.length}</p></div>
<div className="detail-item"><label>Completed</label><p>{tasks.filter(t => t.status === 'client_approved').length}</p></div>
<div className="detail-item"><label>In Progress</label><p>{tasks.filter(t => t.status === 'in_progress').length}</p></div>
<div className="detail-item"><label>Awaiting Review</label><p>{tasks.filter(t => t.status === 'client_review').length}</p></div>
</div>
</div>
</div>
<div className="card-title">Jobs</div>
{tasks.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon">📋</div>
<h3>No jobs yet</h3>
<p>Jobs will appear here when requests come in.</p>
</div>
) : (
<div className="table-wrapper">
<table>
<thead>
<tr>
<th>Job</th>
<th>Assigned To</th>
<th>Version</th>
<th>Status</th>
<th>Submitted</th>
<th></th>
</tr>
</thead>
<tbody>
{tasks.map(task => (
<tr key={task.id}>
<td>
{task.title}
<span style={{ marginLeft: 6, fontWeight: 600, color: 'var(--text-muted)', fontSize: 12 }}>
{'v' + String(task.current_version).padStart(2, '0')}
</span>
</td>
<td style={{ color: task.assigned_name ? 'var(--text-primary)' : 'var(--text-muted)' }}>
{task.assigned_name || 'Unassigned'}
</td>
<td>
<span className="badge badge-not_started">v{task.current_version}</span>
</td>
<td><StatusBadge status={task.status} /></td>
<td style={{ color: 'var(--text-secondary)' }}>
{new Date(task.submitted_at).toLocaleDateString()}
</td>
<td>
<Link to={`/tasks/${task.id}`} className="btn btn-outline btn-sm">View</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Layout>
);
}
+58
View File
@@ -0,0 +1,58 @@
import { Link } from 'react-router-dom';
import Layout from '../../components/Layout';
import StatusBadge from '../../components/StatusBadge';
import { mockProjects, mockTasks } from '../../data/mockData';
export default function Projects() {
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">Projects</div>
<div className="page-subtitle">All client engagements and their tasks.</div>
</div>
</div>
<div className="table-wrapper">
<table>
<thead>
<tr>
<th>Project</th>
<th>Client</th>
<th>Company</th>
<th>Jobs</th>
<th>Status</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{mockProjects.map(project => {
const tasks = mockTasks.filter(t => t.projectId === project.id);
const activeTasks = tasks.filter(t => t.status !== 'approved');
return (
<tr key={project.id}>
<td>
<Link to={`/projects/${project.id}`} className="table-link">{project.name}</Link>
</td>
<td>
{project.clientName}
{!project.clientId && (
<span className="badge badge-not_started" style={{ marginLeft: 6, fontSize: 10 }}>Guest</span>
)}
</td>
<td style={{ color: 'var(--text-secondary)' }}>{project.company || '—'}</td>
<td>
<span style={{ fontWeight: 600 }}>{activeTasks.length}</span>
<span style={{ color: 'var(--text-muted)', fontSize: 12 }}> / {tasks.length} active jobs</span>
</td>
<td><StatusBadge status={project.status} /></td>
<td style={{ color: 'var(--text-secondary)' }}>{project.createdAt}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Layout>
);
}
+95
View File
@@ -0,0 +1,95 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import Layout from '../../components/Layout';
import StatusBadge from '../../components/StatusBadge';
import { supabase } from '../../lib/supabase';
export default function Requests() {
const [submissions, setSubmissions] = useState([]);
const [tasks, setTasks] = useState([]);
const [projects, setProjects] = useState([]);
const [companies, setCompanies] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function load() {
const [{ data: subs }, { data: t }, { data: p }, { data: co }] = await Promise.all([
supabase.from('submissions').select('*').order('submitted_at', { ascending: false }),
supabase.from('tasks').select('*'),
supabase.from('projects').select('*'),
supabase.from('companies').select('id, name'),
]);
setSubmissions(subs || []);
setTasks(t || []);
setProjects(p || []);
setCompanies(co || []);
setLoading(false);
}
load();
}, []);
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">Requests Inbox</div>
<div className="page-subtitle">All incoming submissions initial requests and revisions.</div>
</div>
</div>
{submissions.length === 0 ? (
<div className="empty-state">
<h3>No requests yet</h3>
<p>Client requests will appear here.</p>
</div>
) : submissions.map(sub => {
const task = tasks.find(t => t.id === sub.task_id);
const project = projects.find(p => p.id === task?.project_id);
const company = companies.find(co => co.id === project?.company_id);
return (
<div key={sub.id} className="request-card">
<div className="request-card-header">
<div>
<div className="request-card-title">
{sub.service_type}
{sub.type === 'revision' && (
<span className="badge badge-revision" style={{ marginLeft: 8 }}>Revision v{sub.version_number}</span>
)}
</div>
<div className="request-card-meta">
From <strong>{sub.submitted_by_name}</strong>
{company && (
<> · <Link to={`/companies/${company.id}`} className="table-link">{company.name}</Link></>
)}
{' · '}{new Date(sub.submitted_at).toLocaleDateString()}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<StatusBadge status={task?.status || 'not_started'} />
{task && <Link to={`/tasks/${task.id}`} className="btn btn-outline btn-sm">View Job</Link>}
</div>
</div>
<div style={{ display: 'flex', gap: 24, marginBottom: 12 }}>
<div>
<span style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Deadline</span>
<div style={{ fontSize: 13, marginTop: 2 }}>{sub.deadline || 'Not specified'}</div>
</div>
<div>
<span style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Project</span>
<div style={{ fontSize: 13, marginTop: 2 }}>
{project ? <Link to={`/projects/${project.id}`} className="table-link">{project.name}</Link> : '—'}
</div>
</div>
</div>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.6 }}>{sub.description}</p>
</div>
);
})}
</Layout>
);
}
+378
View File
@@ -0,0 +1,378 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import Layout from '../../components/Layout';
import StatusBadge from '../../components/StatusBadge';
import { supabase } from '../../lib/supabase';
import { sendEmail } from '../../lib/email';
import { useAuth } from '../../context/AuthContext';
const vLabel = (v) => 'v' + String(v).padStart(2, '0');
const MAX_FILES = 20;
const MAX_SIZE_MB = 10;
const MAX_SIZE_BYTES = MAX_SIZE_MB * 1024 * 1024;
const formatSize = (bytes) => bytes < 1024 * 1024 ? (bytes / 1024).toFixed(1) + ' KB' : (bytes / (1024 * 1024)).toFixed(1) + ' MB';
export default function TaskDetail() {
const { id } = useParams();
const navigate = useNavigate();
const { currentUser } = useAuth();
const [task, setTask] = useState(null);
const [project, setProject] = useState(null);
const [company, setCompany] = useState(null);
const [submissions, setSubmissions] = useState([]);
const [teamMembers, setTeamMembers] = useState([]);
const [loading, setLoading] = useState(true);
const [notification, setNotification] = useState(null);
const [showSendForm, setShowSendForm] = useState(false);
const [sendForm, setSendForm] = useState({ files: [], message: '' });
const [fileErrors, setFileErrors] = useState([]);
const [saving, setSaving] = useState(false);
useEffect(() => {
async function load() {
const { data: t } = await supabase.from('tasks').select('*').eq('id', id).single();
if (!t) { setLoading(false); return; }
setTask(t);
const [{ data: p }, { data: subs }, { data: team }] = await Promise.all([
supabase.from('projects').select('*').eq('id', t.project_id).single(),
supabase.from('submissions').select('*, delivery:deliveries(*, files:delivery_files(*))').eq('task_id', id).order('version_number'),
supabase.from('profiles').select('*').eq('role', 'team'),
]);
setProject(p);
setSubmissions(subs || []);
setTeamMembers(team || []);
if (p) {
const { data: co } = await supabase.from('companies').select('*').eq('id', p.company_id).single();
setCompany(co);
}
setLoading(false);
}
load();
}, [id]);
const updateStatus = async (newStatus, message) => {
setSaving(true);
await supabase.from('tasks').update({ status: newStatus }).eq('id', id);
setTask(t => ({ ...t, status: newStatus }));
setNotification(message);
setSaving(false);
};
const handleStart = () => updateStatus('in_progress', '✓ Job started — status set to In Progress.');
const handleOnHold = () => updateStatus('on_hold', '✓ Job placed on hold.');
const handleResume = () => updateStatus('in_progress', '✓ Job resumed — back to In Progress.');
const handleAssign = async (e) => {
const member = teamMembers.find(m => m.id === e.target.value);
await supabase.from('tasks').update({
assigned_to: e.target.value || null,
assigned_name: member?.name || null,
}).eq('id', id);
setTask(t => ({ ...t, assigned_to: e.target.value || null, assigned_name: member?.name || null }));
setNotification('✓ Job assigned.');
};
const handleFileChange = (e) => {
const incoming = Array.from(e.target.files);
const combined = [...sendForm.files, ...incoming];
const errors = [];
if (combined.length > MAX_FILES) errors.push(`Maximum ${MAX_FILES} files allowed.`);
incoming.filter(f => f.size > MAX_SIZE_BYTES).forEach(f => errors.push(`"${f.name}" exceeds 10 MB limit.`));
if (errors.length > 0) { setFileErrors(errors); return; }
setFileErrors([]);
setSendForm(f => ({ ...f, files: combined }));
e.target.value = '';
};
const removeFile = (index) => {
setSendForm(f => ({ ...f, files: f.files.filter((_, i) => i !== index) }));
setFileErrors([]);
};
const handleSendToClient = async (e) => {
e.preventDefault();
setSaving(true);
const latestSub = submissions[submissions.length - 1];
if (!latestSub) return;
const uploadedFiles = [];
for (const file of sendForm.files) {
const path = `${id}/${Date.now()}_${file.name}`;
const { data: uploaded } = await supabase.storage.from('deliveries').upload(path, file);
if (uploaded) uploadedFiles.push({ name: file.name, storage_path: path, size: file.size });
}
const { data: delivery } = await supabase.from('deliveries').insert({
submission_id: latestSub.id,
sent_by: currentUser?.name,
message: sendForm.message,
}).select().single();
if (delivery && uploadedFiles.length > 0) {
await supabase.from('delivery_files').insert(
uploadedFiles.map(f => ({ ...f, delivery_id: delivery.id }))
);
}
await supabase.from('tasks').update({ status: 'client_review' }).eq('id', id);
setTask(t => ({ ...t, status: 'client_review' }));
const { data: subs } = await supabase
.from('submissions')
.select('*, delivery:deliveries(*, files:delivery_files(*))')
.eq('task_id', id)
.order('version_number');
setSubmissions(subs || []);
if (company?.email) {
sendEmail('sent_to_client', company.email, {
clientFirstName: company.name,
serviceType: task.title,
projectName: project?.name,
message: sendForm.message,
taskId: id,
});
}
setShowSendForm(false);
setSendForm({ files: [], message: '' });
setNotification(`✓ Sent to client — ${company?.name} has been notified with ${uploadedFiles.length} file${uploadedFiles.length !== 1 ? 's' : ''}.`);
setSaving(false);
};
const getFileUrl = async (path) => {
const { data } = await supabase.storage.from('deliveries').createSignedUrl(path, 3600);
if (data?.signedUrl) window.open(data.signedUrl, '_blank');
};
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
if (!task) return <Layout><p>Job not found.</p></Layout>;
const titleWithVersion = `${task.title} ${vLabel(task.current_version)}`;
return (
<Layout>
<button className="back-link" onClick={() => navigate(`/projects/${task.project_id}`)}>
Back to {project?.name}
</button>
<div className="page-header">
<div>
<div className="page-title">{titleWithVersion}</div>
<div className="page-subtitle">
<Link to={`/companies/${company?.id}`} style={{ color: 'var(--accent)' }}>{company?.name}</Link>
{' '}
<Link to={`/projects/${project?.id}`} style={{ color: 'var(--accent)' }}>{project?.name}</Link>
</div>
</div>
<StatusBadge status={task.status} />
</div>
{notification && <div className="notification notification-success">{notification}</div>}
{showSendForm && (
<div className="card" style={{ marginBottom: 24, border: '1px solid var(--accent)', borderRadius: 12 }}>
<div className="card-title">Send to Client {company?.name}</div>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 16 }}>
Upload the completed file and add an optional message. An email will be sent to <strong>{company?.email}</strong>.
</p>
<form onSubmit={handleSendToClient}>
<div className="form-group">
<label>
Attach Files *
<span style={{ fontWeight: 400, color: 'var(--text-muted)', marginLeft: 6 }}>
Up to {MAX_FILES} files · Max {MAX_SIZE_MB} MB each
</span>
</label>
<div style={{
border: `2px dashed ${sendForm.files.length > 0 ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 8, padding: '20px 16px', textAlign: 'center',
background: sendForm.files.length > 0 ? '#fffbeb' : '#fafafa',
}}>
<input type="file" multiple onChange={handleFileChange} style={{ display: 'none' }} id="file-upload" />
<label htmlFor="file-upload" style={{ cursor: 'pointer' }}>
<div style={{ fontSize: 24, marginBottom: 6 }}>📎</div>
<div style={{ fontWeight: 600, fontSize: 13 }}>
{sendForm.files.length > 0 ? `${sendForm.files.length} file${sendForm.files.length !== 1 ? 's' : ''} added` : 'Click to add files'}
</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 4 }}>Any file type accepted</div>
</label>
</div>
{fileErrors.map((err, i) => <div key={i} style={{ fontSize: 12, color: 'var(--danger)', marginTop: 4 }}> {err}</div>)}
{sendForm.files.length > 0 && (
<div style={{ marginTop: 10, display: 'flex', flexDirection: 'column', gap: 6 }}>
{sendForm.files.map((file, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'white', borderRadius: 8, border: '1px solid var(--border)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span>📄</span>
<div>
<div style={{ fontSize: 13, fontWeight: 600 }}>{file.name}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{formatSize(file.size)}</div>
</div>
</div>
<button type="button" onClick={() => removeFile(i)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--danger)', fontSize: 16 }}></button>
</div>
))}
</div>
)}
</div>
<div className="form-group">
<label>Message to Client <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
<textarea
placeholder={`Hi ${company?.name}, your ${task.title} is ready for review!`}
value={sendForm.message}
onChange={e => setSendForm(f => ({ ...f, message: e.target.value }))}
style={{ minHeight: 80 }}
/>
</div>
<div className="action-buttons">
<button type="submit" className="btn btn-success" disabled={sendForm.files.length === 0 || saving}>
{saving ? 'Uploading...' : `✉️ Send to Client${sendForm.files.length > 0 ? ` (${sendForm.files.length} file${sendForm.files.length !== 1 ? 's' : ''})` : ''}`}
</button>
<button type="button" className="btn btn-outline" onClick={() => { setShowSendForm(false); setSendForm({ files: [], message: '' }); }}>Cancel</button>
</div>
</form>
</div>
)}
<div className="grid-2" style={{ marginBottom: 24 }}>
<div className="card">
<div className="card-title">Job Details</div>
<div className="detail-grid" style={{ marginBottom: 16 }}>
<div className="detail-item"><label>Status</label><p><StatusBadge status={task.status} /></p></div>
<div className="detail-item"><label>Version</label><p style={{ fontWeight: 700, fontSize: 16 }}>{vLabel(task.current_version)}</p></div>
<div className="detail-item"><label>Submitted</label><p>{new Date(task.submitted_at).toLocaleDateString()}</p></div>
<div className="detail-item"><label>Completed</label><p>{task.completed_at ? new Date(task.completed_at).toLocaleDateString() : '—'}</p></div>
</div>
<div className="form-group">
<label>Assigned To</label>
<select value={task.assigned_to || ''} onChange={handleAssign} style={{ width: '100%' }}>
<option value="">Unassigned</option>
{teamMembers.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
</select>
</div>
<div className="action-buttons" style={{ marginTop: 8 }}>
{task.status === 'not_started' && (
<button className="btn btn-primary" onClick={handleStart} disabled={saving}> Start Job</button>
)}
{task.status === 'in_progress' && !showSendForm && (
<>
<button className="btn btn-success" onClick={() => setShowSendForm(true)}> Send to Client</button>
<button className="btn btn-outline" onClick={handleOnHold} disabled={saving}> Put On Hold</button>
</>
)}
{task.status === 'on_hold' && (
<button className="btn btn-primary" onClick={handleResume} disabled={saving}> Resume</button>
)}
{task.status === 'client_review' && (
<div style={{ padding: '10px 14px', background: '#f5f3ff', borderRadius: 8, fontSize: 13, color: '#7c3aed', fontWeight: 500 }}>
Awaiting client review no action needed.
</div>
)}
{task.status === 'client_approved' && (
<div style={{ padding: '10px 14px', background: '#f0fdf4', borderRadius: 8, fontSize: 13, color: '#16a34a', fontWeight: 500 }}>
Client approved this job.
</div>
)}
</div>
</div>
<div className="card">
<div className="card-title">Current Request Notes</div>
{submissions.length === 0 ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>No request notes yet.</p>
) : (() => {
const latest = submissions[submissions.length - 1];
return (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}>
<span style={{ fontWeight: 700, fontSize: 13 }}>{vLabel(latest.version_number - 1)}</span>
<StatusBadge status={latest.type} />
<span style={{ fontSize: 12, color: 'var(--text-muted)', marginLeft: 'auto' }}>
{latest.submitted_by_name} · {new Date(latest.submitted_at).toLocaleDateString()}
</span>
</div>
<div className="detail-grid" style={{ marginBottom: 14 }}>
<div className="detail-item"><label>Service Type</label><p>{latest.service_type}</p></div>
<div className="detail-item"><label>Deadline</label><p>{latest.deadline || '—'}</p></div>
</div>
<div>
<label style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Description</label>
<p style={{ marginTop: 8, fontSize: 14, lineHeight: 1.7, color: 'var(--text-primary)', background: 'var(--bg)', padding: '12px 14px', borderRadius: 8, border: '1px solid var(--border)', whiteSpace: 'pre-wrap' }}>
{latest.description}
</p>
</div>
</>
);
})()}
</div>
</div>
{submissions.length > 0 && (
<div className="card">
<div className="card-title">Version History</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{submissions.map((sub, i) => {
const delivery = sub.delivery;
const isCurrent = i === submissions.length - 1;
return (
<div key={sub.id} style={{ borderRadius: 8, border: `1px solid ${isCurrent ? '#fde68a' : 'var(--border)'}`, background: isCurrent ? '#fffbeb' : 'var(--bg)', overflow: 'hidden' }}>
<div style={{ padding: '12px 16px', display: 'flex', alignItems: 'center', gap: 10, borderBottom: '1px solid var(--border)' }}>
<span style={{ fontWeight: 700, fontSize: 13 }}>{vLabel(sub.version_number - 1)}</span>
<StatusBadge status={sub.type} />
{isCurrent && <span style={{ fontSize: 11, color: '#d97706', fontWeight: 600 }}>Current</span>}
<span style={{ fontSize: 12, color: 'var(--text-muted)', marginLeft: 'auto' }}>
{sub.submitted_by_name} · {new Date(sub.submitted_at).toLocaleDateString()}
</span>
</div>
<div style={{ padding: '12px 16px', borderBottom: delivery ? '1px solid var(--border)' : 'none' }}>
<div style={{ display: 'flex', gap: 24, marginBottom: 8 }}>
<div>
<span style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Service</span>
<div style={{ fontSize: 13, marginTop: 2 }}>{sub.service_type}</div>
</div>
<div>
<span style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Deadline</span>
<div style={{ fontSize: 13, marginTop: 2 }}>{sub.deadline || '—'}</div>
</div>
</div>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{sub.description}</p>
</div>
{delivery ? (
<div style={{ padding: '10px 16px', background: '#f0fdf4', borderTop: '1px solid #bbf7d0' }}>
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: '#16a34a', marginBottom: 8 }}>
Delivered by {delivery.sent_by} on {new Date(delivery.sent_at).toLocaleDateString()}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{(delivery.files || []).map((file, fi) => (
<div key={fi} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 10px', background: 'white', borderRadius: 6, border: '1px solid #bbf7d0' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span>📄</span>
<span style={{ fontSize: 13, fontWeight: 500 }}>{file.name}</span>
{file.size > 0 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{formatSize(file.size)}</span>}
</div>
<button className="btn btn-outline btn-sm" onClick={() => getFileUrl(file.storage_path)}>
📥 View
</button>
</div>
))}
</div>
</div>
) : (
<div style={{ padding: '10px 16px', background: '#f8f8f8' }}>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>📎 No file delivered yet for this version.</span>
</div>
)}
</div>
);
})}
</div>
</div>
)}
</Layout>
);
}