13ef1f4ded
- Unified Tasks page: 3 role render blocks → 1, normalized row shape, single renderRow/sort/tabs - Fixed role scoping: external = all tasks in member projects, client = all tasks in company projects - Fixed client bug: was scoped by submitted_by, now company→project→tasks - Aligned dashboard client scope to match tasks page - Hot tasks dashboard: now filters to active statuses only (not completed/invoiced/paid) - Removed Projects.jsx (dead), fixed /project/ → /projects/ links - Renamed all page files to match folder path (Team*, External*, Client* prefixes) - Renamed: RequestsPage→Tasks, Settings→Profile, CompaniesPage→Companies, ProjectDetailPage→ProjectDetail - Dropped dead fetches from Tasks page (invoices, invoice_items, subcontractor_invoice_items) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
297 lines
12 KiB
React
297 lines
12 KiB
React
import { useState, useEffect, useRef, useMemo } from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import Layout from '../../components/Layout';
|
||
import LoadingButton from '../../components/LoadingButton';
|
||
import { supabase } from '../../lib/supabase';
|
||
import { useAuth } from '../../context/AuthContext';
|
||
import { sendEmail } from '../../lib/email';
|
||
|
||
const INVOICE_TODAY = new Date().toISOString().split('T')[0];
|
||
|
||
function newItem(description = '', unit_price = '', quantity = 1, taskId = null, isRevision = false) {
|
||
return { id: crypto.randomUUID(), description, unit_price, quantity, task_id: taskId, is_revision: isRevision };
|
||
}
|
||
|
||
function genNumber(count) {
|
||
const year = new Date().getFullYear();
|
||
return `INVSUB-${year}-${String(count + 1).padStart(3, '0')}`;
|
||
}
|
||
|
||
export default function MyInvoiceCreate() {
|
||
const navigate = useNavigate();
|
||
const { currentUser } = useAuth();
|
||
|
||
const [invoiceNumber, setInvoiceNumber] = useState('');
|
||
const [completedTasks, setCompletedTasks] = useState([]);
|
||
const [loadingTasks, setLoadingTasks] = useState(true);
|
||
const [items, setItems] = useState([newItem()]);
|
||
const [notes, setNotes] = useState('');
|
||
const [saving, setSaving] = useState(false);
|
||
const [error, setError] = useState('');
|
||
const dragItem = useRef(null);
|
||
|
||
const rate = Number(currentUser?.brand_book_rate || 60);
|
||
|
||
useEffect(() => {
|
||
async function load() {
|
||
const [{ data: tasks }, { data: existingInvoices }, { data: nextNum }] = await Promise.all([
|
||
supabase
|
||
.from('tasks')
|
||
.select('id, title, current_version, project:projects(name)')
|
||
.eq('status', 'client_approved')
|
||
.eq('assigned_to', currentUser.id)
|
||
.order('title'),
|
||
supabase
|
||
.from('subcontractor_invoices')
|
||
.select('id, status, items:subcontractor_invoice_items(task_id)')
|
||
.in('status', ['submitted', 'paid']),
|
||
supabase.rpc('get_next_sub_invoice_number'),
|
||
]);
|
||
|
||
setInvoiceNumber(nextNum || genNumber(0));
|
||
|
||
const invoicedTaskIds = new Set(
|
||
(existingInvoices || []).flatMap(inv => (inv.items || []).map(i => i.task_id).filter(Boolean))
|
||
);
|
||
setCompletedTasks((tasks || []).filter(t => !invoicedTaskIds.has(t.id)));
|
||
setLoadingTasks(false);
|
||
}
|
||
load();
|
||
}, []);
|
||
|
||
const addedTaskIds = useMemo(() => new Set(items.map(i => i.task_id).filter(Boolean)), [items]);
|
||
|
||
const addTask = (task) => {
|
||
if (addedTaskIds.has(task.id)) return;
|
||
const version = task.current_version || 0;
|
||
const baseDesc = task.project?.name ? `${task.project.name} • ${task.title}` : task.title;
|
||
const toAdd = [newItem(`${baseDesc} – R00`, rate, 1, task.id, false)];
|
||
for (let v = 1; v <= version; v++) {
|
||
toAdd.push(newItem(`${baseDesc} – R${String(v).padStart(2, '0')}`, 30, 1, task.id, true));
|
||
}
|
||
setItems(prev => {
|
||
if (prev.length === 1 && !prev[0].description && !prev[0].unit_price) return toAdd;
|
||
return [...prev, ...toAdd];
|
||
});
|
||
};
|
||
|
||
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 handleDrop = (targetIndex) => {
|
||
if (dragItem.current === null || dragItem.current === targetIndex) { dragItem.current = null; return; }
|
||
setItems(prev => {
|
||
const next = [...prev];
|
||
const [moved] = next.splice(dragItem.current, 1);
|
||
next.splice(targetIndex, 0, moved);
|
||
return next;
|
||
});
|
||
dragItem.current = null;
|
||
};
|
||
|
||
const total = items.reduce((s, i) => s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0), 0);
|
||
|
||
const handleSubmit = async () => {
|
||
const valid = items.filter(i => String(i.description).trim());
|
||
if (!valid.length) { setError('Add at least one line item.'); return; }
|
||
setSaving(true);
|
||
setError('');
|
||
try {
|
||
const { data: inv, error: invErr } = await supabase
|
||
.from('subcontractor_invoices')
|
||
.insert({
|
||
profile_id: currentUser.id,
|
||
invoice_number: invoiceNumber.trim(),
|
||
status: 'submitted',
|
||
notes: notes.trim(),
|
||
submitted_at: new Date().toISOString(),
|
||
})
|
||
.select()
|
||
.single();
|
||
if (invErr) throw invErr;
|
||
|
||
const { error: itemsErr } = await supabase.from('subcontractor_invoice_items').insert(
|
||
valid.map((item, idx) => ({
|
||
invoice_id: inv.id,
|
||
task_id: item.task_id || null,
|
||
description: String(item.description).trim(),
|
||
quantity: Number(item.quantity) || 1,
|
||
unit_price: Number(item.unit_price) || 0,
|
||
sort_order: idx,
|
||
}))
|
||
);
|
||
if (itemsErr) throw itemsErr;
|
||
|
||
const total = valid.reduce((s, i) => s + (Number(i.unit_price) || 0) * (Number(i.quantity) || 1), 0);
|
||
sendEmail('subcontractor_invoice_submitted', 'hello@fourgebranding.com', {
|
||
subName: currentUser.name,
|
||
invoiceNumber: inv.invoice_number,
|
||
total: total.toFixed(2),
|
||
invoiceId: inv.id,
|
||
}).catch(err => console.error('Sub invoice notification failed:', err));
|
||
|
||
navigate(`/my-invoices-sub/${inv.id}`);
|
||
} catch (err) {
|
||
setError(err.message);
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Layout>
|
||
<button className="back-link" onClick={() => navigate('/my-invoices-sub')}>← Back to Invoices</button>
|
||
|
||
<div className="page-header">
|
||
<div>
|
||
<div className="page-title">New Invoice</div>
|
||
<div className="page-subtitle">
|
||
Invoice date: {new Date(INVOICE_TODAY).toLocaleDateString()} · {invoiceNumber}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{error && <div className="notification notification-info" style={{ marginBottom: 16 }}>{error}</div>}
|
||
|
||
<div className="card" style={{ marginBottom: 24 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||
<div className="card-title" style={{ marginBottom: 0 }}>Completed Tasks</div>
|
||
{completedTasks.length > 0 && !loadingTasks && (
|
||
<button
|
||
className="btn btn-outline btn-sm"
|
||
onClick={() => completedTasks.forEach(task => { if (!addedTaskIds.has(task.id)) addTask(task); })}
|
||
>
|
||
+ Add All
|
||
</button>
|
||
)}
|
||
</div>
|
||
{loadingTasks ? (
|
||
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading...</p>
|
||
) : completedTasks.length === 0 ? (
|
||
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>No completed tasks available to invoice.</p>
|
||
) : (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||
{completedTasks.map(task => {
|
||
const alreadyAdded = addedTaskIds.has(task.id);
|
||
return (
|
||
<div key={task.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
|
||
<div>
|
||
<div style={{ fontSize: 13, fontWeight: 400 }}>
|
||
{task.project?.name ? `${task.project.name} • ` : ''}{task.title}
|
||
</div>
|
||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||
{(() => {
|
||
const v = task.current_version || 0;
|
||
const parts = [`R00 New Book $${rate.toFixed(2)}`];
|
||
if (v > 0) parts.push(`+ ${v} Revision${v > 1 ? 's' : ''} @ $30ea`);
|
||
return parts.join(' · ');
|
||
})()}
|
||
</div>
|
||
</div>
|
||
<button
|
||
className={`btn btn-sm ${alreadyAdded ? 'btn-outline' : 'btn-primary'}`}
|
||
onClick={() => !alreadyAdded && addTask(task)}
|
||
disabled={alreadyAdded}
|
||
>
|
||
{alreadyAdded ? 'Added' : '+ Add'}
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="card" style={{ marginBottom: 24 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||
<div className="card-title" style={{ marginBottom: 0 }}>Line Items</div>
|
||
</div>
|
||
|
||
<div style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 80px 120px 120px 40px', gap: 8, marginBottom: 8 }}>
|
||
{['', 'Type', 'Description', 'Qty / Hrs', 'Rate', 'Total', ''].map((h, i) => (
|
||
<div key={i} style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', textAlign: i > 3 ? 'right' : 'left' }}>{h}</div>
|
||
))}
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||
{items.map((item, index) => (
|
||
<div
|
||
key={item.id}
|
||
onDragOver={e => e.preventDefault()}
|
||
onDrop={() => handleDrop(index)}
|
||
style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 80px 120px 120px 40px', gap: 8, alignItems: 'center' }}
|
||
>
|
||
<div
|
||
draggable
|
||
onDragStart={e => { dragItem.current = index; e.dataTransfer.effectAllowed = 'move'; }}
|
||
style={{ cursor: 'grab', color: 'var(--text-muted)', fontSize: 14, textAlign: 'center', userSelect: 'none' }}
|
||
>⠿</div>
|
||
<span className={`badge ${item.is_revision ? 'badge-client_revision' : 'badge-initial'}`}>
|
||
{item.is_revision ? 'Revision' : 'New'}
|
||
</span>
|
||
<input
|
||
type="text"
|
||
placeholder="Description..."
|
||
value={item.description}
|
||
onChange={e => updateItem(item.id, 'description', e.target.value)}
|
||
style={{ margin: 0 }}
|
||
/>
|
||
<input
|
||
type="number"
|
||
min="0.5"
|
||
step="0.5"
|
||
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: 400, 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: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>Total</div>
|
||
<div style={{ fontSize: 26, fontWeight: 400, 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 for the team..."
|
||
value={notes}
|
||
onChange={e => setNotes(e.target.value)}
|
||
style={{ minHeight: 80 }}
|
||
/>
|
||
</div>
|
||
|
||
<div className="action-buttons">
|
||
<LoadingButton className="btn btn-primary" onClick={handleSubmit} loading={saving} loadingText="Submitting...">
|
||
Submit Invoice
|
||
</LoadingButton>
|
||
<button className="btn btn-outline" onClick={() => navigate('/my-invoices-sub')} disabled={saving}>Cancel</button>
|
||
</div>
|
||
</Layout>
|
||
);
|
||
}
|