Files
fourge-portal/src/pages/external/ExternalMyInvoiceCreate.jsx
T
Krao Hasanee 13ef1f4ded Session 2026-05-30: tasks/projects unification, code consolidation, file renames
- 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>
2026-05-30 10:19:23 -04:00

297 lines
12 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, 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>
);
}