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
+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>
);
}