Add Project Files section and show company name for external users on project detail
This commit is contained in:
+69
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
|
||||
export default function ExternalProjects() {
|
||||
const { currentUser } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentUser?.id) { setLoading(false); return; }
|
||||
supabase
|
||||
.from('projects')
|
||||
.select('id, name, status, company:companies(name)')
|
||||
.order('created_at', { ascending: false })
|
||||
.then(({ data, error: err }) => {
|
||||
if (err) setError(err.message);
|
||||
else setProjects(data || []);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [currentUser?.id]);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">Projects</div>
|
||||
<div className="page-subtitle">All projects you are assigned to.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="card" style={{ color: 'var(--danger)', marginBottom: 16 }}>{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p>
|
||||
) : projects.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>No projects yet</h3>
|
||||
<p>Projects will appear here once the team assigns you to one.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Project</th>
|
||||
<th>Client</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{projects.map(p => (
|
||||
<tr key={p.id} style={{ cursor: 'pointer' }} onClick={() => navigate(`/projects/${p.id}`)}>
|
||||
<td style={{ fontWeight: 600 }}>{p.name}</td>
|
||||
<td style={{ color: 'var(--text-muted)' }}>{p.company?.name || '—'}</td>
|
||||
<td style={{ textTransform: 'capitalize', color: 'var(--text-muted)' }}>{p.status || 'Active'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
+282
@@ -0,0 +1,282 @@
|
||||
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';
|
||||
|
||||
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')
|
||||
.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 isRevision = (task.current_version || 0) > 0;
|
||||
const desc = task.project?.name ? `${task.project.name} • ${task.title}` : task.title;
|
||||
const price = isRevision ? 30 : rate;
|
||||
setItems(prev => {
|
||||
if (prev.length === 1 && !prev[0].description && !prev[0].unit_price) {
|
||||
return [newItem(desc, price, 1, task.id, isRevision)];
|
||||
}
|
||||
return [...prev, newItem(desc, price, 1, task.id, isRevision)];
|
||||
});
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
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 isRevision = (task.current_version || 0) > 0;
|
||||
const price = isRevision ? 30 : rate;
|
||||
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: 6, border: '1px solid var(--border)' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>
|
||||
{task.project?.name ? `${task.project.name} • ` : ''}{task.title}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
{isRevision ? 'Revision' : 'New'} · ${price.toFixed(2)}{isRevision ? '/hr' : ''}
|
||||
</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: 600, 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: 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 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>
|
||||
);
|
||||
}
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, 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 { generateSubcontractorPOPDF } from '../../lib/invoice';
|
||||
|
||||
const STATUS_COLOR = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
|
||||
const STATUS_LABEL = { draft: 'Draft', submitted: 'Submitted', paid: 'Paid' };
|
||||
|
||||
function fmt(val) {
|
||||
return `$${Number(val || 0).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function invoiceTotal(items) {
|
||||
return (items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
|
||||
}
|
||||
|
||||
export default function MyInvoiceDetail() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { currentUser } = useAuth();
|
||||
|
||||
const [invoice, setInvoice] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const { data, error: err } = await supabase
|
||||
.from('subcontractor_invoices')
|
||||
.select('*, items:subcontractor_invoice_items(*)')
|
||||
.eq('id', id)
|
||||
.single();
|
||||
if (err || !data) { setError('Invoice not found.'); setLoading(false); return; }
|
||||
setInvoice(data);
|
||||
setLoading(false);
|
||||
}
|
||||
load();
|
||||
}, [id]);
|
||||
|
||||
async function handleSubmit() {
|
||||
setSubmitting(true);
|
||||
const { error: err } = await supabase
|
||||
.from('subcontractor_invoices')
|
||||
.update({ status: 'submitted', submitted_at: new Date().toISOString() })
|
||||
.eq('id', id);
|
||||
if (err) setError(err.message);
|
||||
else setInvoice(i => ({ ...i, status: 'submitted', submitted_at: new Date().toISOString() }));
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!window.confirm(`Delete invoice ${invoice.invoice_number}? This cannot be undone.`)) return;
|
||||
setDeleting(true);
|
||||
const { error: err } = await supabase.from('subcontractor_invoices').delete().eq('id', id);
|
||||
if (err) { setError(err.message); setDeleting(false); return; }
|
||||
navigate('/my-invoices-sub');
|
||||
}
|
||||
|
||||
async function handleDownload() {
|
||||
setDownloading(true);
|
||||
try {
|
||||
const total = invoiceTotal(invoice.items);
|
||||
await generateSubcontractorPOPDF({
|
||||
po_number: invoice.invoice_number,
|
||||
status: 'paid',
|
||||
profile: { name: currentUser?.name, email: currentUser?.email },
|
||||
project: { name: 'Subcontractor Invoice', company: { name: 'Fourge Branding' } },
|
||||
date: invoice.created_at?.split('T')[0],
|
||||
due_date: invoice.paid_at?.split('T')[0] || invoice.created_at?.split('T')[0],
|
||||
amount: total,
|
||||
description: 'Payment received for completed subcontractor work.',
|
||||
notes: invoice.notes,
|
||||
items: (invoice.items || []).map((item, idx) => ({
|
||||
description: item.description,
|
||||
amount: Number(item.unit_price || 0) * Number(item.quantity || 1),
|
||||
sort_order: item.sort_order ?? idx,
|
||||
})),
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
setDownloading(false);
|
||||
}
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
if (!invoice) return <Layout><p style={{ padding: 24 }}>{error || 'Invoice not found.'}</p></Layout>;
|
||||
|
||||
const total = invoiceTotal(invoice.items);
|
||||
const sortedItems = [...(invoice.items || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<button className="back-link" onClick={() => navigate('/my-invoices-sub')}>← Back to Invoices</button>
|
||||
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">{invoice.invoice_number}</div>
|
||||
<div className="page-subtitle">Fourge Branding</div>
|
||||
</div>
|
||||
<div className="action-buttons">
|
||||
<span className={`badge badge-${STATUS_COLOR[invoice.status]}`} style={{ fontSize: 13, padding: '6px 14px' }}>
|
||||
{STATUS_LABEL[invoice.status]}
|
||||
</span>
|
||||
{invoice.status === 'paid' && (
|
||||
<LoadingButton
|
||||
className="btn btn-primary"
|
||||
loading={downloading}
|
||||
loadingText="Generating PDF..."
|
||||
disabled={downloading}
|
||||
onClick={handleDownload}
|
||||
>
|
||||
Download Receipt
|
||||
</LoadingButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="notification notification-info" style={{ marginBottom: 16 }}>{error}</div>}
|
||||
|
||||
<div className="grid-2" style={{ marginBottom: 24 }}>
|
||||
<div className="card">
|
||||
<div className="card-title">From</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 700 }}>{currentUser?.name || 'Subcontractor'}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 4 }}>{currentUser?.email}</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 #</label>
|
||||
<p style={{ fontWeight: 700 }}>{invoice.invoice_number}</p>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<label>Created</label>
|
||||
<p>{new Date(invoice.created_at).toLocaleDateString()}</p>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<label>Terms</label>
|
||||
<p>NET 30 from client payment</p>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<label>Status</label>
|
||||
<p>
|
||||
<span className={`badge badge-${STATUS_COLOR[invoice.status]}`}>
|
||||
{STATUS_LABEL[invoice.status]}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
{invoice.submitted_at && (
|
||||
<div className="detail-item">
|
||||
<label>Submitted</label>
|
||||
<p>{new Date(invoice.submitted_at).toLocaleDateString()}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="detail-item">
|
||||
<label>Total</label>
|
||||
<p style={{ fontSize: 18, fontWeight: 700, color: 'var(--accent)' }}>{fmt(total)}</p>
|
||||
</div>
|
||||
{invoice.paid_at && (
|
||||
<div className="detail-item">
|
||||
<label>Paid On</label>
|
||||
<p style={{ color: 'var(--success, #16a34a)', fontWeight: 600 }}>
|
||||
{new Date(invoice.paid_at).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
|
||||
</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 style={{ width: 100 }}>Type</th>
|
||||
<th>Description</th>
|
||||
<th style={{ textAlign: 'center' }}>Qty / Hrs</th>
|
||||
<th style={{ textAlign: 'right' }}>Rate</th>
|
||||
<th style={{ textAlign: 'right' }}>Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedItems.map(item => (
|
||||
<tr key={item.id}>
|
||||
<td>
|
||||
<span className={`badge ${item.task_id ? 'badge-in_progress' : 'badge-initial'}`}>
|
||||
{item.task_id ? 'Task' : 'Other'}
|
||||
</span>
|
||||
</td>
|
||||
<td>{item.description}</td>
|
||||
<td style={{ textAlign: 'center' }}>{item.quantity}</td>
|
||||
<td style={{ textAlign: 'right' }}>{fmt(item.unit_price)}</td>
|
||||
<td style={{ textAlign: 'right', fontWeight: 700 }}>
|
||||
{fmt(Number(item.unit_price) * Number(item.quantity || 1))}
|
||||
</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)' }}>{fmt(total)}</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' && (
|
||||
<LoadingButton
|
||||
className="btn btn-primary"
|
||||
loading={submitting}
|
||||
loadingText="Submitting..."
|
||||
disabled={submitting || deleting}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Submit to Team
|
||||
</LoadingButton>
|
||||
)}
|
||||
{invoice.status === 'paid' && (
|
||||
<LoadingButton
|
||||
className="btn btn-success"
|
||||
loading={downloading}
|
||||
loadingText="Generating PDF..."
|
||||
disabled={downloading}
|
||||
onClick={handleDownload}
|
||||
>
|
||||
Download Receipt
|
||||
</LoadingButton>
|
||||
)}
|
||||
{invoice.status !== 'paid' && (
|
||||
<LoadingButton
|
||||
className="btn btn-danger"
|
||||
loading={deleting}
|
||||
loadingText="Deleting..."
|
||||
disabled={submitting || deleting}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
Delete Invoice
|
||||
</LoadingButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Vendored
+93
@@ -0,0 +1,93 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
||||
|
||||
const STATUS_BADGE = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
|
||||
const STATUS_LABEL = { draft: 'Draft', submitted: 'Submitted', paid: 'Paid' };
|
||||
|
||||
function fmt(val) {
|
||||
return `$${Number(val || 0).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function invoiceTotal(items) {
|
||||
return (items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
|
||||
}
|
||||
|
||||
export default function MyInvoices() {
|
||||
const { currentUser } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const cacheKey = `ext-invoices:${currentUser?.id}`;
|
||||
const cached = readPageCache(cacheKey, 3 * 60_000);
|
||||
const [invoices, setInvoices] = useState(() => cached || []);
|
||||
const [loading, setLoading] = useState(() => !cached);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentUser?.id) { setLoading(false); return; }
|
||||
supabase
|
||||
.from('subcontractor_invoices')
|
||||
.select('*, items:subcontractor_invoice_items(*)')
|
||||
.order('created_at', { ascending: false })
|
||||
.then(({ data, error: err }) => {
|
||||
if (err) setError(err.message);
|
||||
else { setInvoices(data || []); writePageCache(cacheKey, data || []); }
|
||||
setLoading(false);
|
||||
});
|
||||
}, [currentUser?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">Invoices</div>
|
||||
<div className="page-subtitle">Submit invoices to Fourge Branding for your completed work.</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 4 }}>Payment terms: NET 30 from the date Fourge receives payment from the client.</div>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={() => navigate('/my-invoices-sub/new')} disabled={loading}>
|
||||
+ New Invoice
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="notification notification-info" style={{ marginBottom: 16 }}>{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<div className="empty-state">Loading invoices...</div>
|
||||
) : invoices.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>No invoices yet</h3>
|
||||
<p>Create your first invoice to get paid for your completed work.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Invoice #</th>
|
||||
<th>Submitted</th>
|
||||
<th>Status</th>
|
||||
<th style={{ textAlign: 'right' }}>Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invoices.map(inv => {
|
||||
const total = invoiceTotal(inv.items);
|
||||
return (
|
||||
<tr key={inv.id} style={{ cursor: 'pointer' }} onClick={() => navigate(`/my-invoices-sub/${inv.id}`)}>
|
||||
<td style={{ fontWeight: 700 }}>{inv.invoice_number}</td>
|
||||
<td>{inv.submitted_at ? new Date(inv.submitted_at).toLocaleDateString() : <span style={{ color: 'var(--text-muted)' }}>—</span>}</td>
|
||||
<td><StatusBadge status={STATUS_BADGE[inv.status]} label={STATUS_LABEL[inv.status]} /></td>
|
||||
<td style={{ textAlign: 'right', fontWeight: 700 }}>{fmt(total)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Vendored
+188
-169
@@ -1,60 +1,47 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { withTimeout } from '../../lib/withTimeout';
|
||||
|
||||
const STATUS_ORDER = ['in_progress', 'not_started', 'client_review', 'needs_revision', 'on_hold', 'client_approved'];
|
||||
|
||||
function sortTasks(tasks) {
|
||||
return [...tasks].sort((a, b) => {
|
||||
const ai = STATUS_ORDER.indexOf(a.status);
|
||||
const bi = STATUS_ORDER.indexOf(b.status);
|
||||
if (ai !== bi) return ai - bi;
|
||||
return String(a.title || '').localeCompare(String(b.title || ''));
|
||||
});
|
||||
}
|
||||
import { getCurrentVersionForTask, getDeadlineSourceSubmission } from '../../lib/taskDeadlines';
|
||||
import { formatDateOnly } from '../../lib/dates';
|
||||
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
||||
|
||||
export default function ExternalMyRequests() {
|
||||
const { currentUser } = useAuth();
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [poItemsByTaskId, setPoItemsByTaskId] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const navigate = useNavigate();
|
||||
const cacheKey = `ext-requests:${currentUser?.id}`;
|
||||
const cached = readPageCache(cacheKey, 3 * 60_000);
|
||||
const [projects, setProjects] = useState(() => cached?.projects || []);
|
||||
const [tasks, setTasks] = useState(() => cached?.tasks || []);
|
||||
const [submissions, setSubmissions] = useState(() => cached?.submissions || []);
|
||||
const [paidTaskIds, setPaidTaskIds] = useState(() => new Set(cached?.paidTaskIds || []));
|
||||
const [loading, setLoading] = useState(() => !cached);
|
||||
const [error, setError] = useState('');
|
||||
const [activeTab, setActiveTab] = useState('active');
|
||||
const [filterProject, setFilterProject] = useState('');
|
||||
const [filterRequester, setFilterRequester] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
if (!currentUser?.id) { setLoading(false); return; }
|
||||
try {
|
||||
// 1. All projects this sub is a member of (RLS filters via project_members)
|
||||
// 2. All tasks in those projects (RLS filters via project_members)
|
||||
// 3. Their PO items — to show PO context (due date, PO#, amount) per task
|
||||
const [
|
||||
{ data: projectData, error: projectError },
|
||||
{ data: taskData, error: taskError },
|
||||
{ data: poItems, error: poError },
|
||||
{ data: subData, error: subError },
|
||||
{ data: paidItems },
|
||||
] = await withTimeout(
|
||||
Promise.all([
|
||||
supabase.from('projects').select('id, name, company_id').order('created_at', { ascending: false }),
|
||||
supabase.from('tasks').select('id, title, status, current_version, project_id, invoiced').order('submitted_at', { ascending: false }),
|
||||
supabase.from('submissions').select('task_id, submitted_by_name, version_number, deadline, is_hot, service_type, submitted_at').order('submitted_at', { ascending: false }),
|
||||
supabase
|
||||
.from('projects')
|
||||
.select('id, name, company:companies(name)')
|
||||
.order('created_at', { ascending: false }),
|
||||
supabase
|
||||
.from('tasks')
|
||||
.select('id, title, status, current_version, project_id')
|
||||
.order('submitted_at', { ascending: false }),
|
||||
supabase
|
||||
.from('subcontractor_po_items')
|
||||
.select(`
|
||||
id, description, amount,
|
||||
task_id,
|
||||
po:subcontractor_payments!inner(
|
||||
id, po_number, status, due_date
|
||||
)
|
||||
`),
|
||||
.from('subcontractor_invoice_items')
|
||||
.select('task_id, invoice:subcontractor_invoices!inner(status)')
|
||||
.eq('subcontractor_invoices.status', 'paid'),
|
||||
]),
|
||||
15000,
|
||||
'External requests load'
|
||||
@@ -62,19 +49,19 @@ export default function ExternalMyRequests() {
|
||||
|
||||
if (projectError) throw projectError;
|
||||
if (taskError) throw taskError;
|
||||
if (poError) throw poError;
|
||||
if (subError) throw subError;
|
||||
|
||||
// Build a map of task_id → PO item for quick lookup
|
||||
const byTask = {};
|
||||
(poItems || []).forEach(item => {
|
||||
if (item.task_id && item.po?.status !== 'cancelled') {
|
||||
byTask[item.task_id] = item;
|
||||
}
|
||||
});
|
||||
const paid = new Set(
|
||||
(paidItems || [])
|
||||
.filter(item => item.invoice?.status === 'paid' && item.task_id)
|
||||
.map(item => item.task_id)
|
||||
);
|
||||
|
||||
setProjects(projectData || []);
|
||||
setTasks(taskData || []);
|
||||
setPoItemsByTaskId(byTask);
|
||||
setSubmissions(subData || []);
|
||||
setPaidTaskIds(paid);
|
||||
writePageCache(cacheKey, { projects: projectData || [], tasks: taskData || [], submissions: subData || [], paidTaskIds: [...paid] });
|
||||
setError('');
|
||||
} catch (err) {
|
||||
console.error('External requests load failed:', err);
|
||||
@@ -86,47 +73,74 @@ export default function ExternalMyRequests() {
|
||||
load();
|
||||
}, [currentUser?.id]);
|
||||
|
||||
const activeTasks = useMemo(() => tasks.filter(t => t.status !== 'client_approved'), [tasks]);
|
||||
const completedTasks = useMemo(() => tasks.filter(t => t.status === 'client_approved'), [tasks]);
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
|
||||
function renderTask(task) {
|
||||
const po = poItemsByTaskId[task.id];
|
||||
const dueDate = po?.po?.due_date
|
||||
? new Date(po.po.due_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
: null;
|
||||
const isFullyClosedTask = (task) => task?.status === 'client_approved' && paidTaskIds.has(task.id);
|
||||
|
||||
const latestTaskGroups = tasks.map(task => {
|
||||
const taskSubs = submissions.filter(s => s.task_id === task.id);
|
||||
const deadlineSource = getDeadlineSourceSubmission(task, taskSubs);
|
||||
if (!deadlineSource) return null;
|
||||
const currentVersion = getCurrentVersionForTask(task, taskSubs);
|
||||
const latestGroup = taskSubs.filter(s => s.version_number === currentVersion);
|
||||
return { task, primary: deadlineSource, group: latestGroup };
|
||||
}).filter(Boolean);
|
||||
|
||||
const projectNames = [...new Map(
|
||||
latestTaskGroups.map(({ task }) => {
|
||||
const p = projects.find(p => p.id === task.project_id);
|
||||
return p ? [p.id, p] : null;
|
||||
}).filter(Boolean)
|
||||
).values()];
|
||||
|
||||
const requesterNames = [...new Set(submissions.map(s => s.submitted_by_name).filter(Boolean))].sort();
|
||||
|
||||
const filteredGroups = latestTaskGroups.filter(({ task, group }) => {
|
||||
if (filterProject && task.project_id !== filterProject) return false;
|
||||
if (filterRequester && !group.some(s => s.submitted_by_name === filterRequester)) return false;
|
||||
return true;
|
||||
}).sort((a, b) => {
|
||||
const aLatest = Math.max(...a.group.map(s => new Date(s.submitted_at).getTime()));
|
||||
const bLatest = Math.max(...b.group.map(s => new Date(s.submitted_at).getTime()));
|
||||
return bLatest - aLatest;
|
||||
});
|
||||
|
||||
const activeGroups = filteredGroups.filter(({ task }) => task?.status !== 'client_approved' && task?.status !== 'client_review');
|
||||
const clientReviewGroups = filteredGroups.filter(({ task }) => task?.status === 'client_review');
|
||||
const completedGroups = filteredGroups.filter(({ task }) => task?.status === 'client_approved' && !isFullyClosedTask(task));
|
||||
const closedGroups = filteredGroups.filter(({ task }) => isFullyClosedTask(task));
|
||||
|
||||
const renderRow = ({ task, primary }) => {
|
||||
const project = projects.find(p => p.id === task?.project_id);
|
||||
const isCompleted = task?.status === 'client_approved';
|
||||
const isFullyClosed = isFullyClosedTask(task);
|
||||
const revisionLabel = `R${String(primary.version_number ?? 0).padStart(2, '0')}`;
|
||||
const deadline = formatDateOnly(primary.deadline, 'Not specified');
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={task.id}
|
||||
to={`/tasks/${task.id}`}
|
||||
className="interactive-row"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(0, 1fr) auto',
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
padding: '12px 14px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', marginBottom: 3 }}>
|
||||
<span style={{ fontWeight: 700, fontSize: 14, color: 'var(--text-primary)' }}>{task.title}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
R{String(task.current_version || 0).padStart(2, '0')}
|
||||
</span>
|
||||
<tr key={task.id} onClick={() => navigate(`/tasks/${task.id}`)} style={{ cursor: 'pointer' }}>
|
||||
<td style={{ fontWeight: 600 }}>{project?.name || 'No project'}</td>
|
||||
<td style={{ fontWeight: 600 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span>{task?.title || primary.service_type}</span>
|
||||
{primary.is_hot ? <span className="badge badge-needs_revision">Hot</span> : null}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{po && <span style={{ color: 'var(--accent)', fontWeight: 600 }}>{po.po?.po_number}</span>}
|
||||
{po?.amount != null && <span>${Number(po.amount).toFixed(2)}</span>}
|
||||
{dueDate && <span>Due {dueDate}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={task.status} />
|
||||
</Link>
|
||||
</td>
|
||||
<td>{revisionLabel}</td>
|
||||
<td>{primary.service_type || 'Request'}</td>
|
||||
<td>{deadline}</td>
|
||||
<td>{isFullyClosed ? <span className="badge badge-client_approved">Paid & Closed</span> : isCompleted ? <span className="badge badge-client_approved">Completed</span> : <StatusBadge status={task?.status || 'not_started'} />}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const tabList = [
|
||||
{ id: 'active', label: 'Active', groups: activeGroups },
|
||||
{ id: 'client-review', label: 'Client Review', groups: clientReviewGroups },
|
||||
{ id: 'completed', label: 'Completed', groups: completedGroups },
|
||||
{ id: 'closed', label: 'Fully Closed', groups: closedGroups },
|
||||
];
|
||||
const currentGroups = tabList.find(t => t.id === activeTab)?.groups || [];
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
@@ -137,103 +151,108 @@ export default function ExternalMyRequests() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stats-grid" style={{ marginBottom: 24 }}>
|
||||
<div className="stat-card stat-card-highlight">
|
||||
<div className="stat-value">{tasks.length}</div>
|
||||
<div className="stat-label">Total Tasks</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{activeTasks.length}</div>
|
||||
<div className="stat-label">Active</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ color: activeTasks.filter(t => t.status === 'client_review').length > 0 ? 'var(--accent)' : undefined }}>
|
||||
{activeTasks.filter(t => t.status === 'client_review').length}
|
||||
</div>
|
||||
<div className="stat-label">Awaiting Review</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{completedTasks.length}</div>
|
||||
<div className="stat-label">Completed</div>
|
||||
{(projectNames.length > 0 || requesterNames.length > 0) && (
|
||||
<div className="card request-toolbar-card" style={{ marginBottom: 16 }}>
|
||||
<div className="request-toolbar-grid">
|
||||
{projectNames.length > 0 && (
|
||||
<div className="request-toolbar-section">
|
||||
<div className="card-title" style={{ marginBottom: 10 }}>Filter By Project</div>
|
||||
<div className="request-filter-row">
|
||||
<button className={`btn btn-sm ${!filterProject ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterProject('')}>All</button>
|
||||
{projectNames.map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
className={`btn btn-sm ${filterProject === p.id ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setFilterProject(f => f === p.id ? '' : p.id)}
|
||||
>
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{requesterNames.length > 0 && (
|
||||
<div className="request-toolbar-section">
|
||||
<div className="card-title" style={{ marginBottom: 10 }}>Filter By Requester</div>
|
||||
<div className="request-filter-row">
|
||||
<button className={`btn btn-sm ${!filterRequester ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterRequester('')}>All</button>
|
||||
{requesterNames.map(name => (
|
||||
<button
|
||||
key={name}
|
||||
className={`btn btn-sm ${filterRequester === name ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setFilterRequester(f => f === name ? '' : name)}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p>
|
||||
) : error ? (
|
||||
<div className="card" style={{ color: 'var(--danger)' }}>{error}</div>
|
||||
) : tasks.length === 0 ? (
|
||||
{submissions.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>No tasks yet</h3>
|
||||
<h3>No requests yet</h3>
|
||||
<p>Tasks will appear here once Fourge assigns you to a project.</p>
|
||||
</div>
|
||||
) : filteredGroups.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>No matching requests</h3>
|
||||
<p>Try clearing the current filters.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 16 }}>
|
||||
{projects.map(project => {
|
||||
const projectTasks = sortTasks(tasks.filter(t => t.project_id === project.id));
|
||||
if (!projectTasks.length) return null;
|
||||
const active = projectTasks.filter(t => t.status !== 'client_approved');
|
||||
const done = projectTasks.filter(t => t.status === 'client_approved');
|
||||
<div>
|
||||
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div className="card-title" style={{ marginBottom: 0, display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
{tabList.map((tab, index) => (
|
||||
<span key={tab.id} style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||||
{index > 0 && <span style={{ color: 'var(--text-muted)' }}>|</span>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
style={{
|
||||
background: 'none', border: 'none', padding: 0, margin: 0, cursor: 'pointer',
|
||||
color: activeTab === tab.id ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
font: 'inherit', textTransform: 'inherit', letterSpacing: 'inherit',
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
<span className="request-company-count" style={{ marginLeft: 6 }}>{tab.groups.length}</span>
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
return (
|
||||
<div key={project.id} className="card" style={{ padding: 0, overflow: 'hidden' }}>
|
||||
{/* Project header */}
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
background: 'var(--card-bg-2)',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, fontSize: 14 }}>{project.name}</div>
|
||||
{project.company?.name && (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{project.company.name}</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{active.length > 0 && <span style={{ fontWeight: 600, color: 'var(--accent)' }}>{active.length} active</span>}
|
||||
{done.length > 0 && <span>{done.length} done</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active tasks */}
|
||||
{active.length > 0 && (
|
||||
<div>
|
||||
{active.map(task => renderTask(task))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Completed tasks — collapsed by default */}
|
||||
{done.length > 0 && (
|
||||
<CompletedGroup tasks={done} renderTask={renderTask} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{currentGroups.length === 0 ? (
|
||||
<div className="empty-state" style={{ padding: '28px 20px' }}>
|
||||
<h3>No {tabList.find(t => t.id === activeTab)?.label.toLowerCase()} requests</h3>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Project</th>
|
||||
<th>Name</th>
|
||||
<th>Revision</th>
|
||||
<th>Request Type</th>
|
||||
<th>Deadline</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{currentGroups.map(group => renderRow(group))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="card" style={{ color: 'var(--danger)', marginTop: 16 }}>{error}</div>}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
function CompletedGroup({ tasks, renderTask }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div style={{ borderTop: tasks.length > 0 ? '1px solid var(--border)' : 'none' }}>
|
||||
<button
|
||||
onClick={() => setOpen(o => !o)}
|
||||
style={{
|
||||
width: '100%', padding: '8px 16px', background: 'none', border: 'none',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
cursor: 'pointer', fontFamily: 'inherit', fontSize: 12, color: 'var(--text-muted)',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 600 }}>{open ? '▲' : '▼'} {tasks.length} completed</span>
|
||||
</button>
|
||||
{open && tasks.map(task => renderTask(task))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user