Add Project Files section and show company name for external users on project detail

This commit is contained in:
Krao Hasanee
2026-05-14 15:01:32 -04:00
parent eee0885811
commit c32f9d1366
24 changed files with 1729 additions and 771 deletions
+9 -1
View File
@@ -55,7 +55,15 @@
"Bash(npx vercel@latest --prod)",
"Bash(git add *)",
"Bash(git commit -m ' *)",
"Bash(git push *)"
"Bash(git push *)",
"Bash(cat .env.local)",
"Bash(cat .env)",
"Bash(vercel env *)",
"Bash(python3 -m json.tool)",
"Bash(xargs -0 '-I{}' bash -c 'name=$\\(basename \"{}\" .jsx\\); grep -q \"$name\" \"/Users/kraohasanee/Documents/40-49 Fourge Branding/41 Website/fourge-portal/src/App.jsx\" || echo \"UNLINKED: {}\"')",
"mcp__plugin_supabase_supabase__execute_sql",
"mcp__plugin_supabase_supabase__apply_migration",
"Bash(git commit *)"
]
}
}
+19
View File
@@ -428,6 +428,25 @@ export default async function handler(req, res) {
});
}
if (req.method === 'POST' && action === 'rename') {
const newName = safeName(req.body?.name, '');
if (!newName) return json(res, 400, { error: 'New name is required.' });
const type = req.body?.type || 'file';
const endpoint = type === 'dir'
? `/api2/repos/${encodeURIComponent(config.repoId)}/dir/?p=${encodeURIComponent(resolved.seafilePath)}`
: `/api2/repos/${encodeURIComponent(config.repoId)}/file/?p=${encodeURIComponent(resolved.seafilePath)}`;
const body = new URLSearchParams({ operation: 'rename', newname: newName });
await seafileRequest(config, endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
return json(res, 200, { success: true });
}
if (req.method === 'POST' && action === 'move') {
const srcPath = req.body?.srcPath;
const dstDir = req.body?.dstDir;
+8
View File
@@ -28,6 +28,10 @@ const FileSharing = lazy(() => import('./pages/team/FileSharing'));
const FourgePasswords = lazy(() => import('./pages/team/FourgePasswords'));
const ExternalMyRequests = lazy(() => import('./pages/external/MyRequests'));
const MyPurchaseOrders = lazy(() => import('./pages/external/MyPurchaseOrders'));
const ExternalMyInvoices = lazy(() => import('./pages/external/MyInvoices'));
const ExternalProjects = lazy(() => import('./pages/external/ExternalProjects'));
const ExternalMyInvoiceDetail = lazy(() => import('./pages/external/MyInvoiceDetail'));
const ExternalMyInvoiceCreate = lazy(() => import('./pages/external/MyInvoiceCreate'));
const ClientDashboard = lazy(() => import('./pages/client/ClientDashboard'));
const MyCompany = lazy(() => import('./pages/client/MyCompany'));
const MyRequests = lazy(() => import('./pages/client/MyRequests'));
@@ -67,6 +71,10 @@ export default function App() {
<Route path="/server-status" element={<ProtectedRoute role="team"><ServerStatus /></ProtectedRoute>} />
<Route path="/assigned-requests" element={<ProtectedRoute role="external"><ExternalMyRequests /></ProtectedRoute>} />
<Route path="/my-purchase-orders" element={<ProtectedRoute role="external"><MyPurchaseOrders /></ProtectedRoute>} />
<Route path="/my-projects-sub" element={<ProtectedRoute role="external"><ExternalProjects /></ProtectedRoute>} />
<Route path="/my-invoices-sub" element={<ProtectedRoute role="external"><ExternalMyInvoices /></ProtectedRoute>} />
<Route path="/my-invoices-sub/new" element={<ProtectedRoute role="external"><ExternalMyInvoiceCreate /></ProtectedRoute>} />
<Route path="/my-invoices-sub/:id" element={<ProtectedRoute role="external"><ExternalMyInvoiceDetail /></ProtectedRoute>} />
<Route path="/settings" element={<ProtectedRoute><Settings /></ProtectedRoute>} />
+3 -2
View File
@@ -63,7 +63,8 @@ function ExternalNav({ onNav }) {
const links = [
{ to: '/dashboard', label: 'Dashboard' },
{ to: '/assigned-requests', label: 'Requests' },
{ to: '/my-purchase-orders', label: 'Purchase Orders' },
{ to: '/my-projects-sub', label: 'Projects' },
{ to: '/my-invoices-sub', label: 'Invoices' },
{ to: '/file-sharing', label: 'File Sharing' },
{ to: '/survey-maker', label: 'Survey Maker' },
{ to: '/brand-book', label: 'Brand Book Maker' },
@@ -74,7 +75,7 @@ function ExternalNav({ onNav }) {
<div className="sidebar-section">
{links.map(({ to, label }, index) => (
<div key={to}>
{index === 2 && (
{index === 4 && (
<>
<div style={{ height: 1, margin: '10px 12px', background: 'var(--border)' }} />
<div style={{ padding: '0 12px 8px', fontSize: 11, fontWeight: 700, letterSpacing: 0.8, textTransform: 'uppercase', color: 'var(--text-muted)' }}>
+29 -1
View File
@@ -519,6 +519,35 @@ body {
position: relative;
}
.file-browser-progress {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
z-index: 10;
background: transparent;
border-radius: 8px 8px 0 0;
overflow: hidden;
}
.file-browser-progress-bar {
height: 100%;
background: var(--accent);
transition: width 0.2s ease;
border-radius: 0;
}
.file-browser-progress-bar.indeterminate {
width: 40% !important;
animation: progress-slide 1.4s ease-in-out infinite;
}
@keyframes progress-slide {
0% { transform: translateX(-100%); }
100% { transform: translateX(350%); }
}
.file-browser-dragging {
border-color: var(--accent);
}
@@ -973,7 +1002,6 @@ select option { background: #222; color: #fff; }
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 18px;
margin-top: 18px;
}
.request-toolbar-actions,
.request-filter-row {
+69
View File
@@ -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
View File
@@ -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
View File
@@ -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>
);
}
+93
View File
@@ -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>
);
}
+184 -165
View File
@@ -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');
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 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>
{/* Active tasks */}
{active.length > 0 && (
<div>
{active.map(task => renderTask(task))}
{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>
)}
{/* Completed tasks — collapsed by default */}
{done.length > 0 && (
<CompletedGroup tasks={done} renderTask={renderTask} />
)}
</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>
);
}
+1 -123
View File
@@ -4,7 +4,6 @@ import LoadingButton from '../../components/LoadingButton';
import { supabase } from '../../lib/supabase';
import { generateBrandBookEditorPDF } from '../../lib/brandBookEditor';
import { cleanupBrandBookStorage } from '../../lib/deleteHelpers';
import { archiveBrandBooksToLocalZip, restoreBrandBookArchive } from '../../lib/archiveHelpers';
const BUCKET = 'brand-books';
@@ -156,12 +155,6 @@ export default function BrandBook() {
const sitePhotosRef = useRef();
const projectLogoRef = useRef();
const clientLogoRef = useRef();
const restoreArchiveRef = useRef();
const [archivingSelected, setArchivingSelected] = useState(false);
const [archiveStatus, setArchiveStatus] = useState('');
const [restoringArchive, setRestoringArchive] = useState(false);
const [restoreStatus, setRestoreStatus] = useState('');
const [selectedBookIds, setSelectedBookIds] = useState([]);
const [filterCompany, setFilterCompany] = useState('');
useEffect(() => {
@@ -452,73 +445,6 @@ export default function BrandBook() {
fetchBooks();
};
const handleArchiveSelected = async () => {
if (!selectedBookIds.length) {
alert('Select at least one brand book to archive.');
return;
}
if (!window.confirm(`Download one archive for ${selectedBookIds.length} selected brand book${selectedBookIds.length === 1 ? '' : 's'}?`)) return;
setArchivingSelected(true);
try {
const selectedBooks = savedBooks.filter(book => selectedBookIds.includes(book.id));
const archive = await archiveBrandBooksToLocalZip(selectedBookIds, { onProgress: setArchiveStatus });
const shouldDelete = window.confirm(
`Archive downloaded as "${archive.filename}".\n\nRemove those ${archive.bookCount} brand book${archive.bookCount === 1 ? '' : 's'} from Supabase now?`
);
if (shouldDelete) {
for (const book of selectedBooks) {
await cleanupBrandBookStorage(book);
}
await supabase.from('brand_books').delete().in('id', selectedBookIds);
setSelectedBookIds([]);
await fetchBooks();
setArchiveStatus('');
alert(`Archived and removed ${archive.bookCount} brand book${archive.bookCount === 1 ? '' : 's'}.`);
}
} catch (error) {
alert(`Archive failed: ${error.message}`);
} finally {
setArchivingSelected(false);
setArchiveStatus('');
}
};
const handleRestoreArchive = async (e) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
setRestoringArchive(true);
try {
const result = await restoreBrandBookArchive(file, { onProgress: setRestoreStatus });
await fetchBooks();
alert(
result.unlinkedCount
? `Restored ${result.bookCount} brand book${result.bookCount === 1 ? '' : 's'}. ${result.unlinkedCount} restored without a linked client record because the original company no longer exists.`
: `Restored ${result.bookCount} brand book${result.bookCount === 1 ? '' : 's'}.`
);
} catch (error) {
alert(`Restore failed: ${error.message}`);
} finally {
setRestoringArchive(false);
setRestoreStatus('');
}
};
const allSelected = savedBooks.length > 0 && selectedBookIds.length === savedBooks.length;
const toggleSelectedBook = (bookId) => {
setSelectedBookIds(prev => prev.includes(bookId)
? prev.filter(id => id !== bookId)
: [...prev, bookId]
);
};
const toggleSelectAll = () => {
setSelectedBookIds(allSelected ? [] : savedBooks.map(book => book.id));
};
const handleSave = async () => {
if (!bookInfo.clientName.trim()) {
setNotification({ type: 'error', msg: 'Please select a client.' });
@@ -848,48 +774,10 @@ export default function BrandBook() {
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-primary btn-sm" onClick={handleNew}>+ New Brand Book</button>
</div>
<input
ref={restoreArchiveRef}
type="file"
accept=".zip,application/zip"
style={{ display: 'none' }}
onChange={handleRestoreArchive}
/>
</div>
<div className="card request-toolbar-card">
<div className="request-toolbar-section">
<div className="card-title" style={{ marginBottom: 10 }}>Archive</div>
<div className="request-toolbar-actions">
{savedBooks.length > 0 && (
<>
<label className="request-select-all-label">
<input type="checkbox" checked={allSelected} onChange={toggleSelectAll} />
Select All
</label>
<button
className="btn btn-outline btn-sm"
onClick={handleArchiveSelected}
disabled={archivingSelected || !selectedBookIds.length}
>
{archivingSelected ? 'Archiving...' : `Archive Selected (${selectedBookIds.length})`}
</button>
</>
)}
<button
className="btn btn-outline btn-sm"
onClick={() => restoreArchiveRef.current?.click()}
disabled={restoringArchive}
>
{restoringArchive ? 'Restoring...' : 'Restore Brand Book'}
</button>
</div>
{archiveStatus && <div className="request-toolbar-status">{archiveStatus}</div>}
{restoreStatus && <div className="request-toolbar-status">{restoreStatus}</div>}
</div>
{companyNames.length > 0 && (
<div className="request-toolbar-grid">
<div className="card request-toolbar-card" style={{ marginBottom: 16 }}>
<div className="request-toolbar-section">
<div className="card-title" style={{ marginBottom: 10 }}>Filter By Company</div>
<div className="request-filter-row">
@@ -912,7 +800,6 @@ export default function BrandBook() {
</div>
</div>
)}
</div>
{loadingBooks ? (
<p style={{ padding: '24px 0', color: 'var(--text-muted)' }}>Loading...</p>
@@ -927,7 +814,6 @@ export default function BrandBook() {
<table>
<thead>
<tr>
<th></th>
<th>Name</th>
<th>Revision</th>
<th>Sign Count</th>
@@ -946,14 +832,6 @@ export default function BrandBook() {
return (
<tr key={book.id} onClick={() => handleLoad(book)} style={{ cursor: 'pointer' }}>
<td onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedBookIds.includes(book.id)}
onChange={() => toggleSelectedBook(book.id)}
onClick={e => e.stopPropagation()}
/>
</td>
<td style={{ fontWeight: 600 }}>{book.project_name || 'Brand Book'}</td>
<td>{`R${String(book.revision || '01').padStart(2, '0')}`}</td>
<td>{signCount}</td>
+1 -58
View File
@@ -1,9 +1,8 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout';
import { supabase } from '../../lib/supabase';
import { deleteCompanyData } from '../../lib/deleteHelpers';
import { restoreCompanyArchive } from '../../lib/archiveHelpers';
import { readPageCache, writePageCache } from '../../lib/pageCache';
import { syncSeafileFolders } from '../../lib/seafileFolders';
@@ -30,11 +29,8 @@ export default function Companies() {
const [editingUserId, setEditingUserId] = useState(null);
const [editUserVal, setEditUserVal] = useState('');
const [deletingUserId, setDeletingUserId] = useState(null);
const [restoringArchive, setRestoringArchive] = useState(false);
const [restoreStatus, setRestoreStatus] = useState('');
const [filterCompany, setFilterCompany] = useState('');
const [activeTab, setActiveTab] = useState('companies');
const restoreInputRef = useRef(null);
async function load() {
const [{ data: co }, { data: prof }, { data: memberships }] = await Promise.all([
@@ -78,36 +74,6 @@ export default function Companies() {
load();
};
const handleRestoreArchive = async (e) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
setRestoringArchive(true);
try {
const result = await restoreCompanyArchive(file, { onProgress: setRestoreStatus });
await load();
const missing = [];
if (result.missingProfiles.companyProfiles) missing.push(`${result.missingProfiles.companyProfiles} client profiles were not re-linked`);
if (result.missingProfiles.projectMembers) missing.push(`${result.missingProfiles.projectMembers} external project memberships were skipped`);
if (result.missingProfiles.taskAssignments) missing.push(`${result.missingProfiles.taskAssignments} task assignments were cleared`);
if (result.missingProfiles.submissions) missing.push(`${result.missingProfiles.submissions} submission user links were cleared`);
if (result.missingProfiles.invoices) missing.push(`${result.missingProfiles.invoices} invoice creator links were cleared`);
alert(
missing.length
? `Restored ${result.companyCount || 1} compan${(result.companyCount || 1) === 1 ? 'y' : 'ies'}.\n\nNote: ${missing.join('; ')}.`
: `Restored ${result.companyCount || 1} compan${(result.companyCount || 1) === 1 ? 'y' : 'ies'} successfully.`
);
} catch (error) {
alert(`Restore failed: ${error.message}`);
} finally {
setRestoringArchive(false);
setRestoreStatus('');
}
};
const handleEditUserSave = async (userId) => {
if (!editUserVal.trim()) return;
await supabase.from('profiles').update({ name: editUserVal.trim() }).eq('id', userId);
@@ -188,13 +154,6 @@ export default function Companies() {
)}
</div>
</div>
<input
ref={restoreInputRef}
type="file"
accept=".zip,application/zip"
style={{ display: 'none' }}
onChange={handleRestoreArchive}
/>
</div>
<div className="stats-grid" style={{ marginBottom: 24 }}>
@@ -373,22 +332,6 @@ export default function Companies() {
{activeTab === 'companies' && (
<>
<div className="card request-toolbar-card">
<div className="request-toolbar-section">
<div className="card-title" style={{ marginBottom: 10 }}>Restore Archive</div>
<div className="request-toolbar-actions">
<button
className="btn btn-outline btn-sm"
onClick={() => restoreInputRef.current?.click()}
disabled={restoringArchive}
>
{restoringArchive ? 'Restoring...' : 'Restore Archive'}
</button>
</div>
{restoreStatus && <div className="request-toolbar-status">{restoreStatus}</div>}
</div>
</div>
{companies.length === 0 ? (
<div className="empty-state">
<h3>No companies yet</h3>
+87 -6
View File
@@ -264,9 +264,61 @@ function buildRevisionPeople(submissions, tasks, roleFilter) {
}, new Map()).values()];
}
function ExternalDashboard({ currentUser, projects, tasks }) {
function SubcontractorRates({ externals }) {
const [rates, setRates] = useState(() => Object.fromEntries(externals.map(p => [p.id, String(p.brand_book_rate ?? 60)])));
const [saving, setSaving] = useState('');
const [saved, setSaved] = useState('');
const handleSave = async (profile) => {
const rate = parseFloat(rates[profile.id]);
if (isNaN(rate) || rate < 0) return;
setSaving(profile.id);
await supabase.from('profiles').update({ brand_book_rate: rate }).eq('id', profile.id);
setSaving('');
setSaved(profile.id);
setTimeout(() => setSaved(s => s === profile.id ? '' : s), 2000);
};
if (externals.length === 0) return null;
return (
<div className="card" style={{ marginTop: 24 }}>
<div className="card-title" style={{ marginBottom: 4 }}>Subcontractor Rates</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 16 }}>Brand book rate per completed task, used to calculate invoices.</div>
<div style={{ display: 'grid', gap: 10 }}>
{externals.map(profile => (
<div key={profile.id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--card-bg-2)' }}>
<div style={{ flex: 1, fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{profile.name || profile.email}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>$/task</span>
<input
type="number"
min="0"
step="0.01"
value={rates[profile.id] ?? '60'}
onChange={e => setRates(r => ({ ...r, [profile.id]: e.target.value }))}
style={{ width: 80, fontSize: 13, padding: '4px 8px', borderRadius: 6, border: '1px solid var(--border)', background: 'var(--input-bg)', color: 'var(--text-primary)', textAlign: 'right' }}
/>
<button
className="btn btn-outline btn-sm"
disabled={saving === profile.id}
onClick={() => handleSave(profile)}
>
{saving === profile.id ? 'Saving...' : saved === profile.id ? '✓ Saved' : 'Save'}
</button>
</div>
</div>
))}
</div>
</div>
);
}
function ExternalDashboard({ currentUser, projects, tasks, pos }) {
const activeTasks = tasks.filter(t => t.status !== 'client_approved');
const completedTasks = tasks.filter(t => t.status === 'client_approved');
const unpaidAmount = pos.filter(p => !['paid', 'cancelled'].includes(p.status)).reduce((s, p) => s + Number(p.amount || 0), 0);
const paidAmount = pos.filter(p => p.status === 'paid').reduce((s, p) => s + Number(p.amount || 0), 0);
return (
<Layout>
@@ -277,6 +329,27 @@ function ExternalDashboard({ currentUser, projects, tasks }) {
</div>
</div>
<div className="stats-grid" style={{ marginBottom: 24 }}>
<div className="stat-card stat-card-highlight">
<div className="stat-value">{activeTasks.length}</div>
<div className="stat-label">Active Tasks</div>
</div>
<div className="stat-card">
<div className="stat-value">{completedTasks.length}</div>
<div className="stat-label">Completed Tasks</div>
</div>
<div className="stat-card">
<div className="stat-value" style={{ color: unpaidAmount > 0 ? 'var(--accent)' : undefined }}>
${unpaidAmount.toFixed(2)}
</div>
<div className="stat-label">Unpaid Invoices</div>
</div>
<div className="stat-card">
<div className="stat-value">${paidAmount.toFixed(2)}</div>
<div className="stat-label">Paid Invoices</div>
</div>
</div>
{projects.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon">📋</div>
@@ -327,26 +400,30 @@ export default function Dashboard() {
const [tasks, setTasks] = useState(() => cached?.tasks || []);
const [projects, setProjects] = useState(() => cached?.projects || []);
const [submissions, setSubmissions] = useState(() => cached?.submissions || []);
const [pos, setPos] = useState(() => cached?.pos || []);
const [externalProfiles, setExternalProfiles] = useState(() => cached?.externalProfiles || []);
const [loading, setLoading] = useState(() => !cached);
useEffect(() => {
async function load() {
try {
if (isExternal) {
const [{ data: p }, { data: t }] = await withTimeout(Promise.all([
const [{ data: p }, { data: t }, { data: posData }] = await withTimeout(Promise.all([
supabase.from('projects').select('id, 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_payments').select('id, amount, status').eq('profile_id', currentUser.id),
]), 12000, 'Dashboard load');
setProjects(p || []);
setTasks(t || []);
setPos(posData || []);
setSubmissions([]);
writePageCache(cacheKey, { projects: p || [], tasks: t || [], submissions: [] });
writePageCache(cacheKey, { projects: p || [], tasks: t || [], submissions: [], pos: posData || [], externalProfiles: [] });
} else {
const [{ data: t }, { data: p }, { data: submissions }, { data: profiles }] = await withTimeout(Promise.all([
supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_name, assigned_to, completed_at').order('submitted_at', { ascending: false }),
supabase.from('projects').select('id, name, status, company_id'),
supabase.from('submissions').select('task_id, version_number, deadline, type, submitted_by, submitted_by_name, delivery:deliveries(sent_by, sent_at)').order('version_number', { ascending: false }),
supabase.from('profiles').select('id, role, name'),
supabase.from('profiles').select('id, role, name, email, brand_book_rate'),
]), 12000, 'Dashboard load');
const roleByProfileId = new Map((profiles || []).map(profile => [profile.id, profile.role]));
@@ -364,9 +441,11 @@ export default function Dashboard() {
delivery_sender_role: roleByProfileName.get(submission.delivery?.sent_by) || null,
}));
const externals = (profiles || []).filter(pr => pr.role === 'external');
setTasks(tasksWithDeadlines);
setProjects(p || []);
writePageCache(cacheKey, { tasks: tasksWithDeadlines, projects: p || [], submissions: submissionsWithRole });
setExternalProfiles(externals);
writePageCache(cacheKey, { tasks: tasksWithDeadlines, projects: p || [], submissions: submissionsWithRole, pos: [], externalProfiles: externals });
setSubmissions(submissionsWithRole);
}
} catch (error) {
@@ -380,7 +459,7 @@ export default function Dashboard() {
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
if (isExternal) {
return <ExternalDashboard currentUser={currentUser} projects={projects} tasks={tasks} />;
return <ExternalDashboard currentUser={currentUser} projects={projects} tasks={tasks} pos={pos} />;
}
const activeTasks = tasks.filter(t => t.status !== 'client_approved');
@@ -481,6 +560,8 @@ export default function Dashboard() {
/>
</div>
<SubcontractorRates externals={externalProfiles} />
</Layout>
);
}
+168 -42
View File
@@ -41,6 +41,11 @@ export default function FileSharing() {
const [folderName, setFolderName] = useState('');
const [showFolderInput, setShowFolderInput] = useState(false);
const [movingEntry, setMovingEntry] = useState(null);
const [renamingEntry, setRenamingEntry] = useState(null);
const [renameValue, setRenameValue] = useState('');
const [draggedEntry, setDraggedEntry] = useState(null);
const [dragOverFolder, setDragOverFolder] = useState(null);
const [uploadProgress, setUploadProgress] = useState(null);
const fileInputRef = useRef(null);
const breadcrumbs = useMemo(() => pathParts(currentPath), [currentPath]);
@@ -68,10 +73,7 @@ export default function FileSharing() {
setLoading(true);
setError('');
try {
const params = new URLSearchParams({
action: 'list',
path,
});
const params = new URLSearchParams({ action: 'list', path });
if (invalidateUsage) params.set('invalidateUsage', '1');
const data = await apiFetch(`/api/seafile?${params.toString()}`);
@@ -82,7 +84,6 @@ export default function FileSharing() {
if (data.configured === false) setError(data.error || 'Seafile is not configured yet.');
} catch (err) {
setError(err.message);
setUsageBytes(0);
} finally {
setLoading(false);
}
@@ -162,11 +163,42 @@ export default function FileSharing() {
}
};
const renameEntry = async (e) => {
e.preventDefault();
const newName = renameValue.trim();
if (!newName || newName === renamingEntry.name) {
setRenamingEntry(null);
return;
}
setWorking(`rename:${renamingEntry.path}`);
setError('');
try {
await apiFetch('/api/seafile?action=rename', {
method: 'POST',
body: JSON.stringify({ path: renamingEntry.path, name: newName, type: renamingEntry.type }),
});
setRenamingEntry(null);
await loadFiles(currentPath);
} catch (err) {
setError(err.message);
} finally {
setWorking('');
}
};
const startRename = (entry) => {
setMovingEntry(null);
setRenamingEntry(entry);
setRenameValue(entry.name);
};
const uploadFiles = async (files) => {
const selected = Array.from(files || []);
if (!selected.length) return;
setWorking('upload');
setUploadProgress(0);
setError('');
try {
const data = await apiFetch('/api/seafile?action=upload-link', {
@@ -181,52 +213,32 @@ export default function FileSharing() {
formData.append('parent_dir', data.parentDir);
formData.append('replace', '0');
const uploadResponse = await fetch(`${data.uploadLink}${data.uploadLink.includes('?') ? '&' : '?'}ret-json=1`, {
method: 'POST',
body: formData,
await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const url = `${data.uploadLink}${data.uploadLink.includes('?') ? '&' : '?'}ret-json=1`;
xhr.open('POST', url);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) setUploadProgress(Math.round((e.loaded / e.total) * 100));
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve();
else reject(new Error(xhr.responseText || 'Upload failed.'));
};
xhr.onerror = () => reject(new Error('Upload failed.'));
xhr.send(formData);
});
if (!uploadResponse.ok) {
const text = await uploadResponse.text();
throw new Error(text || 'Upload failed.');
}
await loadFiles(currentPath);
} catch (err) {
setError(err.message);
} finally {
setWorking('');
setUploadProgress(null);
if (fileInputRef.current) fileInputRef.current.value = '';
setDragging(false);
}
};
const handleDragEnter = (e) => {
e.preventDefault();
if (!configured || loading || working) return;
setDragging(true);
};
const handleDragOver = (e) => {
e.preventDefault();
if (!configured || loading || working) return;
e.dataTransfer.dropEffect = 'copy';
setDragging(true);
};
const handleDragLeave = (e) => {
e.preventDefault();
if (!e.currentTarget.contains(e.relatedTarget)) setDragging(false);
};
const handleDrop = (e) => {
e.preventDefault();
setDragging(false);
if (!configured || loading || working) return;
if (!e.dataTransfer.files?.length) return;
uploadFiles(e.dataTransfer.files);
};
const moveEntry = async (entry, targetFolderPath) => {
setWorking(`move:${entry.path}`);
setError('');
@@ -244,6 +256,68 @@ export default function FileSharing() {
}
};
// Section-level drag handlers (OS file upload)
const handleDragEnter = (e) => {
e.preventDefault();
if (!configured || loading || working || draggedEntry) return;
setDragging(true);
};
const handleDragOver = (e) => {
e.preventDefault();
if (!configured || loading || working || draggedEntry) return;
e.dataTransfer.dropEffect = 'copy';
setDragging(true);
};
const handleDragLeave = (e) => {
e.preventDefault();
if (!e.currentTarget.contains(e.relatedTarget)) setDragging(false);
};
const handleDrop = (e) => {
e.preventDefault();
setDragging(false);
if (draggedEntry) return;
if (!configured || loading || working) return;
if (!e.dataTransfer.files?.length) return;
uploadFiles(e.dataTransfer.files);
};
// Row drag handlers (entry-to-folder move)
const handleRowDragStart = (e, entry) => {
e.stopPropagation();
setDraggedEntry(entry);
e.dataTransfer.effectAllowed = 'move';
};
const handleRowDragEnd = () => {
setDraggedEntry(null);
setDragOverFolder(null);
};
const handleFolderDragOver = (e, folder) => {
if (!draggedEntry || draggedEntry.path === folder.path) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'move';
setDragOverFolder(folder.path);
};
const handleFolderDragLeave = (e) => {
if (!e.currentTarget.contains(e.relatedTarget)) setDragOverFolder(null);
};
const handleFolderDrop = (e, folder) => {
e.preventDefault();
e.stopPropagation();
setDragOverFolder(null);
if (!draggedEntry || draggedEntry.path === folder.path) return;
const entry = draggedEntry;
setDraggedEntry(null);
moveEntry(entry, folder.path);
};
return (
<Layout>
<div className="page-header">
@@ -260,6 +334,15 @@ export default function FileSharing() {
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{(loading || working || uploadProgress !== null) && (
<div className="file-browser-progress">
<div
className={`file-browser-progress-bar${uploadProgress === null ? ' indeterminate' : ''}`}
style={uploadProgress !== null ? { width: `${uploadProgress}%` } : undefined}
/>
</div>
)}
{dragging && (
<div className="file-drop-overlay">
<div className="file-drop-panel">
@@ -325,6 +408,12 @@ export default function FileSharing() {
{error && <div className="notification notification-info">{error}</div>}
{draggedEntry && (
<div style={{ padding: '6px 12px', fontSize: 12, color: 'var(--text-muted)' }}>
Dragging "{draggedEntry.name}" drop onto a folder to move it
</div>
)}
<div className="file-list">
{currentPath !== '/' && (
<button type="button" className="file-row file-row-button" onClick={() => loadFiles(parentPath)} disabled={loading || Boolean(working)}>
@@ -353,17 +442,49 @@ export default function FileSharing() {
</div>
) : entries.map(entry => {
const isMoving = movingEntry?.path === entry.path;
const isRenaming = renamingEntry?.path === entry.path;
const targetFolders = entries.filter(e => e.type === 'dir' && e.path !== entry.path);
const isDragTarget = entry.type === 'dir' && draggedEntry && draggedEntry.path !== entry.path;
const isDragOver = dragOverFolder === entry.path;
return (
<div className="file-row" key={`${entry.type}:${entry.path}`}>
<div
className={`file-row${isDragOver ? ' file-row-drag-over' : ''}`}
key={`${entry.type}:${entry.path}`}
draggable={!working}
onDragStart={(e) => handleRowDragStart(e, entry)}
onDragEnd={handleRowDragEnd}
onDragOver={isDragTarget ? (e) => handleFolderDragOver(e, entry) : undefined}
onDragLeave={isDragTarget ? handleFolderDragLeave : undefined}
onDrop={isDragTarget ? (e) => handleFolderDrop(e, entry) : undefined}
style={isDragOver ? { outline: '2px solid var(--accent)', borderRadius: 6 } : undefined}
>
<span className="file-icon">{entry.type === 'dir' ? '▣' : '□'}</span>
{entry.type === 'dir' ? (
{isRenaming ? (
<form style={{ display: 'flex', gap: 6, flex: 1 }} onSubmit={renameEntry}>
<input
type="text"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
autoFocus
disabled={Boolean(working)}
style={{ fontSize: 13, padding: '2px 8px', borderRadius: 6, border: '1px solid var(--border)', background: 'var(--input-bg)', color: 'var(--text-primary)', flex: 1, minWidth: 0 }}
onKeyDown={(e) => { if (e.key === 'Escape') setRenamingEntry(null); }}
/>
<LoadingButton type="submit" className="btn btn-outline btn-sm" loading={working === `rename:${entry.path}`} disabled={!renameValue.trim() || Boolean(working)} loadingText="Renaming...">
Save
</LoadingButton>
<button type="button" className="btn btn-outline btn-sm" onClick={() => setRenamingEntry(null)}>Cancel</button>
</form>
) : entry.type === 'dir' ? (
<button type="button" className="file-name file-name-button" onClick={() => openFolder(entry)} disabled={Boolean(working)}>
{entry.name}
</button>
) : (
<span className="file-name">{entry.name}</span>
)}
{!isRenaming && (
<>
<span className="file-meta">{formatBytes(entry.aggregateSize ?? entry.size)}</span>
<span className="file-meta">{formatDate(entry.mtime)}</span>
<span className="file-row-actions">
@@ -393,8 +514,11 @@ export default function FileSharing() {
Download
</LoadingButton>
)}
<button type="button" className="btn btn-outline btn-sm" disabled={Boolean(working)} onClick={() => startRename(entry)}>
Rename
</button>
{targetFolders.length > 0 && (
<button type="button" className="btn btn-outline btn-sm" disabled={Boolean(working)} onClick={() => setMovingEntry(entry)}>
<button type="button" className="btn btn-outline btn-sm" disabled={Boolean(working)} onClick={() => { setRenamingEntry(null); setMovingEntry(entry); }}>
Move
</button>
)}
@@ -404,6 +528,8 @@ export default function FileSharing() {
</>
)}
</span>
</>
)}
</div>
);
})}
+160 -83
View File
@@ -4,7 +4,7 @@ import Layout from '../../components/Layout';
import { supabase } from '../../lib/supabase';
import { readPageCache, writePageCache } from '../../lib/pageCache';
import { exportCPAPackage, generateSubcontractorPOPDF } from '../../lib/invoice';
import { sendEmail } from '../../lib/email';
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
const CATEGORIES = ['Software', 'Contractor', 'Advertising', 'Office', 'Travel', 'Meals', 'Equipment', 'Other'];
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
@@ -68,6 +68,11 @@ export default function Invoices() {
const [subcontractorLoading, setSubcontractorLoading] = useState(true);
const [subcontractorError, setSubcontractorError] = useState('');
const [selectedSubcontractorPOId, setSelectedSubcontractorPOId] = useState('');
const [subInvoices, setSubInvoices] = useState([]);
const [subInvoicesLoading, setSubInvoicesLoading] = useState(true);
const [subInvoicesError, setSubInvoicesError] = useState('');
const [markingPaid, setMarkingPaid] = useState('');
const [expandedSubInvoiceId, setExpandedSubInvoiceId] = useState(null);
useEffect(() => {
async function load() {
@@ -113,6 +118,19 @@ export default function Invoices() {
loadExpenses();
}, []);
useEffect(() => {
async function loadSubInvoices() {
const { data, error: err } = await supabase
.from('subcontractor_invoices')
.select('*, profile:profiles!subcontractor_invoices_profile_id_fkey(id, name, email), items:subcontractor_invoice_items(*)')
.order('created_at', { ascending: false });
if (err) { setSubInvoicesError(err.message); setSubInvoicesLoading(false); return; }
setSubInvoices(data || []);
setSubInvoicesLoading(false);
}
loadSubInvoices();
}, []);
const startEditExpense = (expense) => {
setEditingExpenseId(expense.id);
setNewExpense({
@@ -274,6 +292,56 @@ export default function Invoices() {
updateSubcontractorPO(po, { status: 'paid', paid_at: paidAt }, 'Failed to mark as paid');
};
const handleMarkSubInvoicePaid = async (invoice) => {
setMarkingPaid(invoice.id);
try {
const paidAt = new Date().toISOString();
const { error } = await supabase.from('subcontractor_invoices').update({ status: 'paid', paid_at: paidAt }).eq('id', invoice.id);
if (error) throw error;
const items = invoice.items || [];
const total = items.reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
let pdfBlob = null;
try {
pdfBlob = await generateSubcontractorPOPDF({
po_number: invoice.invoice_number,
status: 'paid',
profile: invoice.profile,
project: { name: 'Subcontractor Invoice', company: { name: 'Fourge Branding' } },
date: invoice.created_at?.split('T')[0],
due_date: paidAt.split('T')[0],
amount: total,
description: 'Payment for completed subcontractor work.',
notes: invoice.notes,
items: items.map((item, idx) => ({
description: item.description,
amount: Number(item.unit_price) * Number(item.quantity || 1),
sort_order: item.sort_order ?? idx,
})),
}, { output: 'blob' });
} catch (pdfErr) {
console.error('PDF generation failed:', pdfErr);
}
if (invoice.profile?.email) {
try {
const attachments = pdfBlob ? [await blobToEmailAttachment(pdfBlob, `${invoice.invoice_number}-receipt.pdf`)] : [];
await sendEmail('receipt_sent', invoice.profile.email, {
invoiceNumber: invoice.invoice_number,
billTo: invoice.profile.name || invoice.profile.email,
total: total.toFixed(2),
paidDate: new Date(paidAt).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }),
}, attachments);
} catch (emailErr) {
console.error('Email failed:', emailErr);
alert(`Marked paid, but email failed: ${emailErr.message || 'unknown error'}`);
}
}
setSubInvoices(prev => prev.map(i => i.id === invoice.id ? { ...i, status: 'paid', paid_at: paidAt } : i));
} catch (err) {
alert(`Failed to mark as paid: ${err.message}`);
}
setMarkingPaid('');
};
const handleReopenSubcontractorPO = async (po) => {
updateSubcontractorPO(po, { status: 'draft', paid_at: null, cancelled_at: null }, 'Failed to reopen PO');
};
@@ -434,7 +502,7 @@ export default function Invoices() {
<div className="card-title" style={{ marginBottom: 0, display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
{[
{ id: 'invoices', label: 'Invoices' },
{ id: 'subcontractor-po', label: 'Subcontractor PO' },
{ id: 'sub-invoices', label: 'Subcontractor Invoices' },
{ id: 'expenses', label: 'Expenses' },
].map((tab, index) => (
<span key={tab.id} style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
@@ -462,9 +530,7 @@ export default function Invoices() {
{activeTab === 'invoices' && (
<button className="btn btn-primary btn-sm" onClick={() => navigate('/invoices/new')}>+ New Invoice</button>
)}
{activeTab === 'subcontractor-po' && (
<button className="btn btn-primary btn-sm" onClick={() => navigate('/subcontractor-pos/new')}>+ New PO</button>
)}
</div>
{activeTab === 'invoices' && (
@@ -774,113 +840,124 @@ export default function Invoices() {
</div>
)}
{activeTab === 'subcontractor-po' && (
<div>
{activeTab === 'sub-invoices' && (
<div>
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12 }}>
<div className="card-title" style={{ marginBottom: 0 }}>POs & Payments</div>
<div className="card-title" style={{ marginBottom: 0 }}>Sub Invoices</div>
<div style={{ display: 'flex', gap: 12, fontSize: 13, fontWeight: 700 }}>
<span style={{ color: 'var(--accent)' }}>${totalPayableSubcontractors.toFixed(2)} payable</span>
<span>${totalPaidSubcontractors.toFixed(2)} paid</span>
<span style={{ color: 'var(--accent)' }}>${subInvoices.filter(i => i.status === 'submitted').reduce((s, i) => s + (i.items || []).reduce((a, x) => a + Number(x.unit_price || 0) * Number(x.quantity || 1), 0), 0).toFixed(2)} pending</span>
<span>${subInvoices.filter(i => i.status === 'paid').reduce((s, i) => s + (i.items || []).reduce((a, x) => a + Number(x.unit_price || 0) * Number(x.quantity || 1), 0), 0).toFixed(2)} paid</span>
</div>
</div>
{subcontractorLoading ? (
{subInvoicesLoading ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Loading...</p>
) : subcontractorError ? (
<p style={{ color: 'var(--danger)', fontSize: 13 }}>{subcontractorError}</p>
) : subcontractorPOs.length === 0 ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>No subcontractor POs yet.</p>
) : subInvoicesError ? (
<p style={{ color: 'var(--danger)', fontSize: 13 }}>{subInvoicesError}</p>
) : subInvoices.length === 0 ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>No sub invoices yet.</p>
) : (
<>
{selectedSubcontractorPO && (
<div style={{ marginBottom: 16, padding: 16, background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 8 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 16, alignItems: 'flex-start', marginBottom: 12 }}>
<div>
<div style={{ fontSize: 18, fontWeight: 800 }}>{selectedSubcontractorPO.po_number || 'Purchase Order'}</div>
<div style={{ color: 'var(--text-muted)', fontSize: 13 }}>
{selectedSubcontractorPO.profile?.name || 'External'} · {selectedSubcontractorPO.project?.name || 'No project'}
</div>
</div>
<button className="btn btn-outline btn-sm" onClick={() => setSelectedSubcontractorPOId('')}>Close</button>
</div>
<div className="detail-grid" style={{ marginBottom: 12 }}>
<div className="detail-item"><label>Status</label><p><span className={`badge badge-${poStatusColor[selectedSubcontractorPO.status] || 'not_started'}`}>{poStatusLabel[selectedSubcontractorPO.status] || selectedSubcontractorPO.status}</span></p></div>
<div className="detail-item"><label>Amount</label><p>${Number(selectedSubcontractorPO.amount).toFixed(2)}</p></div>
<div className="detail-item"><label>Date</label><p>{new Date(selectedSubcontractorPO.date).toLocaleDateString()}</p></div>
<div className="detail-item"><label>Due</label><p>{selectedSubcontractorPO.due_date ? new Date(selectedSubcontractorPO.due_date).toLocaleDateString() : '—'}</p></div>
</div>
{selectedSubcontractorPO.items?.length > 0 && (
<div style={{ display: 'grid', gap: 6, marginBottom: 12 }}>
{selectedSubcontractorPO.items
.slice()
.sort((a, b) => Number(a.sort_order || 0) - Number(b.sort_order || 0))
.map(item => (
<div key={item.id} style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: 13 }}>
<span>{item.description || item.task?.title}</span>
<strong>${Number(item.amount).toFixed(2)}</strong>
</div>
))}
</div>
)}
{selectedSubcontractorPO.notes && (
<p style={{ color: 'var(--text-secondary)', fontSize: 13, whiteSpace: 'pre-wrap' }}>{selectedSubcontractorPO.notes}</p>
)}
<div className="action-buttons" style={{ marginTop: 12 }}>
{selectedSubcontractorPO.status === 'draft' && <button className="btn btn-primary btn-sm" onClick={() => handleSendSubcontractorPO(selectedSubcontractorPO)}>Finalize & Send</button>}
{['sent', 'approved'].includes(selectedSubcontractorPO.status) && <button className="btn btn-outline btn-sm" onClick={() => handleReadyToPaySubcontractorPO(selectedSubcontractorPO)}>Mark Ready</button>}
{selectedSubcontractorPO.status === 'ready_to_pay' && <button className="btn btn-success btn-sm" onClick={() => handleMarkSubcontractorPaid(selectedSubcontractorPO)}>Mark Paid</button>}
{selectedSubcontractorPO.status === 'paid' && <button className="btn btn-outline btn-sm" onClick={() => handleReopenSubcontractorPO(selectedSubcontractorPO)}>Reopen</button>}
<button className="btn btn-outline btn-sm" onClick={() => handleDownloadSubcontractorPO(selectedSubcontractorPO)}>Download</button>
{!['paid', 'cancelled'].includes(selectedSubcontractorPO.status) && <button className="btn btn-outline btn-sm" onClick={() => handleCancelSubcontractorPO(selectedSubcontractorPO)}>Cancel</button>}
<button className="btn btn-danger btn-sm" onClick={() => handleDeleteSubcontractorPO(selectedSubcontractorPO.id)}>Delete</button>
</div>
</div>
)}
<div className="table-wrapper" style={{ marginTop: 0 }}>
<table>
<thead>
<tr>
<th>PO #</th>
<th>Invoice #</th>
<th>Subcontractor</th>
<th>Date</th>
<th>Submitted</th>
<th>Status</th>
<th style={{ textAlign: 'right' }}>Amount</th>
<th style={{ textAlign: 'right' }}>Total</th>
<th style={{ textAlign: 'right' }}>Actions</th>
</tr>
</thead>
<tbody>
{subcontractorPOs.map(po => (
<tr key={po.id} onClick={() => navigate(`/subcontractor-pos/${po.id}`)} style={{ cursor: 'pointer' }}>
{subInvoices.map(inv => {
const total = (inv.items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
const isExpanded = expandedSubInvoiceId === inv.id;
const subInvoiceStatusColor = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
return (
<>
<tr key={inv.id} style={{ cursor: 'pointer' }} onClick={() => setExpandedSubInvoiceId(isExpanded ? null : inv.id)}>
<td style={{ fontWeight: 700 }}>{inv.invoice_number}</td>
<td>
<div style={{ fontWeight: 700 }}>{po.po_number || 'PO'}</div>
<div style={{ fontWeight: 600 }}>{inv.profile?.name || 'External'}</div>
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>{inv.profile?.email || '—'}</div>
</td>
<td>
<div style={{ fontWeight: 600 }}>{po.profile?.name || 'External'}</div>
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>{po.profile?.email || '—'}</div>
<td>{inv.submitted_at ? new Date(inv.submitted_at).toLocaleDateString() : <span style={{ color: 'var(--text-muted)' }}></span>}</td>
<td><span className={`badge badge-${subInvoiceStatusColor[inv.status] || 'not_started'}`} style={{ textTransform: 'capitalize' }}>{inv.status}</span></td>
<td style={{ textAlign: 'right', fontWeight: 700 }}>${total.toFixed(2)}</td>
<td style={{ textAlign: 'right' }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 6 }} onClick={e => e.stopPropagation()}>
{inv.status === 'submitted' && (
<button
className="btn btn-success btn-sm"
disabled={markingPaid === inv.id}
onClick={() => handleMarkSubInvoicePaid(inv)}
>
{markingPaid === inv.id ? 'Processing…' : 'Mark Paid'}
</button>
)}
{inv.status === 'paid' && (
<button
className="btn btn-outline btn-sm"
onClick={() => generateSubcontractorPOPDF({
po_number: inv.invoice_number, status: 'paid',
profile: inv.profile,
project: { name: 'Subcontractor Invoice', company: { name: 'Fourge Branding' } },
date: inv.created_at?.split('T')[0],
due_date: inv.paid_at?.split('T')[0],
amount: total,
description: 'Payment for completed subcontractor work.',
notes: inv.notes,
items: (inv.items || []).map((item, idx) => ({ description: item.description, amount: Number(item.unit_price) * Number(item.quantity || 1), sort_order: item.sort_order ?? idx })),
}).catch(err => alert(err.message))}
>
Receipt
</button>
)}
</div>
</td>
<td>
<div>{new Date(po.paid_at || po.date).toLocaleDateString()}</div>
{po.due_date && <div style={{ color: 'var(--text-muted)', fontSize: 12 }}>Due {new Date(po.due_date).toLocaleDateString()}</div>}
</td>
<td>
<span className={`badge badge-${poStatusColor[po.status] || 'not_started'}`} style={{ textTransform: 'capitalize' }}>
{poStatusLabel[po.status] || po.status}
</span>
</td>
<td style={{ textAlign: 'right', fontWeight: 700 }}>${Number(po.amount).toFixed(2)}</td>
</tr>
{isExpanded && (
<tr key={`${inv.id}-detail`}>
<td colSpan={6} style={{ background: 'var(--bg)', padding: '12px 16px' }}>
{inv.items?.length > 0 ? (
<table style={{ width: '100%', fontSize: 13 }}>
<thead>
<tr>
<th style={{ textAlign: 'left', fontWeight: 600, paddingBottom: 6 }}>Description</th>
<th style={{ textAlign: 'right', fontWeight: 600, paddingBottom: 6 }}>Qty</th>
<th style={{ textAlign: 'right', fontWeight: 600, paddingBottom: 6 }}>Unit Price</th>
<th style={{ textAlign: 'right', fontWeight: 600, paddingBottom: 6 }}>Amount</th>
</tr>
</thead>
<tbody>
{(inv.items || []).slice().sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)).map(item => (
<tr key={item.id}>
<td style={{ paddingBottom: 4 }}>{item.description}</td>
<td style={{ textAlign: 'right', paddingBottom: 4 }}>{item.quantity}</td>
<td style={{ textAlign: 'right', paddingBottom: 4 }}>${Number(item.unit_price).toFixed(2)}</td>
<td style={{ textAlign: 'right', fontWeight: 700, paddingBottom: 4 }}>${(Number(item.unit_price) * Number(item.quantity || 1)).toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
) : <span style={{ color: 'var(--text-muted)', fontSize: 13 }}>No line items.</span>}
{inv.notes && <p style={{ marginTop: 8, fontSize: 13, color: 'var(--text-secondary)', whiteSpace: 'pre-wrap' }}>{inv.notes}</p>}
{inv.paid_at && <p style={{ marginTop: 4, fontSize: 12, color: 'var(--text-muted)' }}>Paid {new Date(inv.paid_at).toLocaleDateString()}</p>}
</td>
</tr>
)}
</>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
)}
</div>
)}
</Layout>
);
}
+93 -6
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout';
import StatusBadge from '../../components/StatusBadge';
@@ -35,6 +35,10 @@ export default function ProjectDetail() {
const [externalProfiles, setExternalProfiles] = useState([]);
const [selectedExternal, setSelectedExternal] = useState('');
const [addingMember, setAddingMember] = useState(false);
const [projectFiles, setProjectFiles] = useState([]);
const [uploadingFile, setUploadingFile] = useState(false);
const fileInputRef = useRef(null);
const requesterOptions = [
...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)`, email: currentUser.email || '' }] : []),
...companyUsers.filter(user => user.id !== currentUser?.id),
@@ -47,18 +51,20 @@ export default function ProjectDetail() {
if (!p) return;
setProject(p);
const [{ data: co }, { data: t }, { data: users }, { data: pm }, { data: ext }] = await Promise.all([
const [{ data: co }, { data: t }, { data: users }, { data: pm }, { data: ext }, { data: pf }] = 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 }),
supabase.from('profiles').select('id, name, email').eq('company_id', p.company_id).eq('role', 'client'),
supabase.from('project_members').select('*, profile:profiles(id, name, email)').eq('project_id', id),
supabase.from('profiles').select('id, name, email').eq('role', 'external').order('name'),
supabase.from('project_files').select('*').eq('project_id', id).order('created_at', { ascending: false }),
]);
setCompany(co);
setTasks(t || []);
setCompanyUsers(users || []);
setMembers(pm || []);
setExternalProfiles(ext || []);
setProjectFiles(pf || []);
} catch (error) {
console.error('ProjectDetail load failed:', error);
} finally {
@@ -152,6 +158,38 @@ export default function ProjectDetail() {
setMembers(prev => prev.filter(m => m.profile_id !== profileId));
};
const handleUploadFile = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setUploadingFile(true);
const path = `${id}/${Date.now()}_${file.name}`;
const { error: upErr } = await supabase.storage.from('project-files').upload(path, file);
if (upErr) { alert('Upload failed: ' + upErr.message); setUploadingFile(false); return; }
const { data: rec } = await supabase.from('project_files').insert({
project_id: id,
name: file.name,
storage_path: path,
size: file.size,
uploaded_by: currentUser.id,
uploaded_by_name: currentUser.name,
}).select().single();
if (rec) setProjectFiles(prev => [rec, ...prev]);
if (fileInputRef.current) fileInputRef.current.value = '';
setUploadingFile(false);
};
const handleDeleteFile = async (file) => {
if (!window.confirm(`Delete "${file.name}"?`)) return;
await supabase.storage.from('project-files').remove([file.storage_path]);
await supabase.from('project_files').delete().eq('id', file.id);
setProjectFiles(prev => prev.filter(f => f.id !== file.id));
};
const handleDownloadFile = async (file) => {
const { data } = await supabase.storage.from('project-files').createSignedUrl(file.storage_path, 60);
if (data?.signedUrl) window.open(data.signedUrl, '_blank');
};
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>;
@@ -185,11 +223,13 @@ export default function ProjectDetail() {
</div>
)}
<div className="page-subtitle">
{!isExternal && company && (
{company && (
<>
<Link to={`/companies/${company.id}`} style={{ color: 'var(--accent)' }}>
{company.name}
</Link>
{isExternal ? (
<span style={{ color: 'var(--text-secondary)' }}>{company.name}</span>
) : (
<Link to={`/companies/${company.id}`} style={{ color: 'var(--accent)' }}>{company.name}</Link>
)}
{' · '}
</>
)}
@@ -313,6 +353,53 @@ export default function ProjectDetail() {
</div>
</div>
<div className="card" style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<div className="card-title" style={{ margin: 0 }}>Project Files</div>
{!isExternal && (
<>
<button
className="btn btn-outline btn-sm"
onClick={() => fileInputRef.current?.click()}
disabled={uploadingFile}
>{uploadingFile ? 'Uploading...' : '+ Upload File'}</button>
<input ref={fileInputRef} type="file" style={{ display: 'none' }} onChange={handleUploadFile} />
</>
)}
</div>
{projectFiles.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)', margin: 0 }}>No files uploaded yet.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{projectFiles.map(f => (
<div key={f.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{f.name}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
{f.uploaded_by_name && `${f.uploaded_by_name} · `}
{new Date(f.created_at).toLocaleDateString()}
{f.size ? ` · ${(f.size / 1024).toFixed(0)} KB` : ''}
</div>
</div>
<div style={{ display: 'flex', gap: 6 }}>
<button
className="btn btn-outline btn-sm"
onClick={() => handleDownloadFile(f)}
>Download</button>
{!isExternal && (
<button
onClick={() => handleDeleteFile(f)}
style={{ background: 'none', border: 'none', color: 'var(--danger)', cursor: 'pointer', fontSize: 16, padding: '0 4px' }}
title="Delete file"
></button>
)}
</div>
</div>
))}
</div>
)}
</div>
<div className="card-title">Jobs</div>
{tasks.length === 0 ? (
<div className="empty-state">
+5 -134
View File
@@ -1,12 +1,10 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect } from 'react';
import { Link, 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 { serviceTypes } from '../../data/mockData';
import { cleanupTaskStorage } from '../../lib/deleteHelpers';
import { archiveCompletedJobsToLocalZip, restoreCompletedJobsArchive } from '../../lib/archiveHelpers';
import { readPageCache, writePageCache } from '../../lib/pageCache';
import { withTimeout } from '../../lib/withTimeout';
import { getCurrentVersionForTask, getDeadlineSourceSubmission } from '../../lib/taskDeadlines';
@@ -40,12 +38,6 @@ export default function Requests() {
const [addSaving, setAddSaving] = useState(false);
const [addError, setAddError] = useState('');
const [addRequestKey, setAddRequestKey] = useState(() => crypto.randomUUID());
const [archivingSelected, setArchivingSelected] = useState(false);
const [archiveStatus, setArchiveStatus] = useState('');
const [restoringArchive, setRestoringArchive] = useState(false);
const [restoreStatus, setRestoreStatus] = useState('');
const [selectedTaskIds, setSelectedTaskIds] = useState([]);
const restoreInputRef = useRef(null);
const requesterOptions = [
...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)`, email: currentUser.email || '' }] : []),
...companyUsers.filter(user => user.id !== currentUser?.id),
@@ -85,7 +77,6 @@ export default function Requests() {
.filter(item => item.task_id && paidInvoiceIds.has(item.invoice_id))
.map(item => item.task_id)
);
setSelectedTaskIds(prev => prev.filter(id => (t || []).some(task => task.id === id && task.status === 'client_approved' && !(task.invoiced && closedTaskIds.has(task.id)))));
} catch (error) {
console.error('Requests load failed:', error);
setSubmissions([]);
@@ -191,61 +182,6 @@ export default function Requests() {
}
};
const handleArchiveSelected = async () => {
if (!selectedTaskIds.length) {
alert('Select at least one completed job to archive.');
return;
}
if (!window.confirm(`Download an archive for ${selectedTaskIds.length} selected completed job${selectedTaskIds.length === 1 ? '' : 's'}?`)) return;
setArchivingSelected(true);
try {
const archive = await archiveCompletedJobsToLocalZip(selectedTaskIds, { onProgress: setArchiveStatus });
const shouldDelete = window.confirm(
`Archive downloaded as "${archive.filename}".\n\nRemove those completed jobs from Supabase now?`
);
if (shouldDelete) {
await cleanupTaskStorage(selectedTaskIds);
await supabase.from('tasks').delete().in('id', selectedTaskIds);
setSelectedTaskIds([]);
await load();
setArchiveStatus('');
alert(`Archived and removed ${archive.taskCount} completed job${archive.taskCount === 1 ? '' : 's'}.`);
}
} catch (error) {
alert(`Archive failed: ${error.message}`);
} finally {
setArchivingSelected(false);
setArchiveStatus('');
}
};
const handleRestoreArchive = async (e) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
setRestoringArchive(true);
try {
const result = await restoreCompletedJobsArchive(file, { onProgress: setRestoreStatus });
await load();
const notes = [];
if (result.clearedAssignments) notes.push(`${result.clearedAssignments} assignments cleared`);
if (result.clearedSubmissionUsers) notes.push(`${result.clearedSubmissionUsers} submission user links cleared`);
alert(
notes.length
? `Restored ${result.taskCount} completed job${result.taskCount === 1 ? '' : 's'}.\n\nNote: ${notes.join('; ')}.`
: `Restored ${result.taskCount} completed job${result.taskCount === 1 ? '' : 's'}.`
);
} catch (error) {
alert(`Restore failed: ${error.message}`);
} finally {
setRestoringArchive(false);
setRestoreStatus('');
}
};
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
const requesterNames = [...new Set(submissions.map(s => s.submitted_by_name).filter(Boolean))].sort();
@@ -266,21 +202,6 @@ export default function Requests() {
return { task, primary: deadlineSource, group: latestGroup };
}).filter(Boolean);
const completedTaskIds = latestTaskGroups
.filter(({ task }) => task.status === 'client_approved' && !isFullyClosedTask(task))
.map(({ task }) => task.id);
const allCompletedSelected = completedTaskIds.length > 0 && selectedTaskIds.length === completedTaskIds.length;
const toggleSelectedTask = (taskId) => {
setSelectedTaskIds(prev => prev.includes(taskId)
? prev.filter(id => id !== taskId)
: [...prev, taskId]
);
};
const toggleSelectAllCompleted = () => {
setSelectedTaskIds(allCompletedSelected ? [] : completedTaskIds);
};
const filteredGroups = latestTaskGroups.filter(({ task, group }) => {
const project = projects.find(p => p.id === task?.project_id);
if (filterCompany && project?.company_id !== filterCompany) return false;
@@ -296,7 +217,7 @@ export default function Requests() {
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 }, showCheckbox = false) => {
const renderRow = ({ task, primary }) => {
const project = projects.find(p => p.id === task?.project_id);
const company = companies.find(co => co.id === project?.company_id);
const isCompleted = task?.status === 'client_approved';
@@ -306,17 +227,6 @@ export default function Requests() {
return (
<tr key={task.id} onClick={() => task && navigate(`/tasks/${task.id}`)} style={{ cursor: task ? 'pointer' : 'default' }}>
{showCheckbox && (
<td onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedTaskIds.includes(task.id)}
disabled={!isCompleted}
onChange={() => toggleSelectedTask(task.id)}
onClick={e => e.stopPropagation()}
/>
</td>
)}
<td style={{ fontWeight: 600 }}>{project?.name || 'No project'}</td>
<td style={{ fontWeight: 600 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
@@ -349,13 +259,6 @@ export default function Requests() {
{showAddForm ? 'Cancel' : '+ Add Request'}
</button>
</div>
<input
ref={restoreInputRef}
type="file"
accept=".zip,application/zip"
style={{ display: 'none' }}
onChange={handleRestoreArchive}
/>
</div>
{showAddForm && (
@@ -463,38 +366,8 @@ export default function Requests() {
</div>
)}
<div className="card request-toolbar-card">
<div className="request-toolbar-section">
<div className="card-title" style={{ marginBottom: 10 }}>Archive</div>
<div className="request-toolbar-actions">
{completedTaskIds.length > 0 && (
<>
<label className="request-select-all-label">
<input type="checkbox" checked={allCompletedSelected} onChange={toggleSelectAllCompleted} />
Select All Completed
</label>
<button
className="btn btn-outline btn-sm"
onClick={handleArchiveSelected}
disabled={archivingSelected || !selectedTaskIds.length}
>
{archivingSelected ? 'Archiving...' : `Archive Selected (${selectedTaskIds.length})`}
</button>
</>
)}
<button
className="btn btn-outline btn-sm"
onClick={() => restoreInputRef.current?.click()}
disabled={restoringArchive}
>
{restoringArchive ? 'Restoring...' : 'Restore Archive'}
</button>
</div>
{archiveStatus && <div className="request-toolbar-status">{archiveStatus}</div>}
{restoreStatus && <div className="request-toolbar-status">{restoreStatus}</div>}
</div>
{(companies.length > 0 || requesterNames.length > 0) && (
<div className="card request-toolbar-card" style={{ marginBottom: 16 }}>
<div className="request-toolbar-grid">
{companies.length > 0 && (
<div className="request-toolbar-section">
@@ -513,7 +386,6 @@ export default function Requests() {
</div>
</div>
)}
{requesterNames.length > 0 && (
<div className="request-toolbar-section">
<div className="card-title" style={{ marginBottom: 10 }}>Filter By Requester</div>
@@ -532,8 +404,8 @@ export default function Requests() {
</div>
)}
</div>
)}
</div>
)}
{submissions.length === 0 ? (
<div className="empty-state">
@@ -649,7 +521,6 @@ export default function Requests() {
<table>
<thead>
<tr>
<th></th>
<th>Project</th>
<th>Name</th>
<th>Revision</th>
@@ -660,7 +531,7 @@ export default function Requests() {
</tr>
</thead>
<tbody>
{completedGroups.map(group => renderRow(group, true))}
{completedGroups.map(group => renderRow(group))}
</tbody>
</table>
</div>
+25 -31
View File
@@ -102,10 +102,9 @@ export default function TaskDetail() {
const currentPrimarySubmission = getCurrentPrimarySubmission(submissions, getRevisionBaseline(task, submissions));
const currentDelivery = currentPrimarySubmission?.delivery || null;
const currentDeliveryFiles = currentDelivery?.files || [];
const canTeamApprove =
!isExternal
&& task?.status === 'client_review'
&& currentPrimarySubmission?.submitted_by === currentUser?.id;
const canApprove =
currentUser?.role === 'client'
&& task?.status === 'client_review';
useEffect(() => {
async function load() {
@@ -373,9 +372,13 @@ export default function TaskDetail() {
const handleTeamApprove = async () => {
setSaving(true);
await supabase.from('tasks').update({ status: 'client_approved', completed_at: new Date().toISOString() }).eq('id', id);
const { error } = await supabase.from('tasks').update({ status: 'client_approved', completed_at: new Date().toISOString() }).eq('id', id);
if (error) {
setNotification(`✗ Error approving: ${error.message}`);
} else {
setTask(t => ({ ...t, status: 'client_approved', completed_at: new Date().toISOString() }));
setNotification('✓ Job approved.');
}
setSaving(false);
};
@@ -538,7 +541,8 @@ export default function TaskDetail() {
e.preventDefault();
setSaving(true);
try {
const newVersion = getRevisionBaseline(task, submissions) + 1;
const currentPrimary = getCurrentPrimarySubmission(submissions, getRevisionBaseline(task, submissions));
if (!currentPrimary) throw new Error('No submission found for this task.');
const uploadedFiles = [];
for (const file of workForm.files) {
@@ -548,26 +552,15 @@ export default function TaskDetail() {
if (uploaded) uploadedFiles.push({ name: file.name, storage_path: path, size: file.size });
}
const { data: sub, error: subError } = await supabase.from('submissions').insert({
task_id: id,
version_number: newVersion,
type: 'initial',
service_type: submissions[0]?.service_type || '',
description: workForm.description.trim() || `Work uploaded by ${currentUser.name}`,
submitted_by: currentUser.id,
submitted_by_name: currentUser.name,
}).select().single();
if (subError) throw new Error(subError.message);
if (sub && uploadedFiles.length > 0) {
if (uploadedFiles.length > 0) {
const { error: filesError } = await supabase.from('submission_files').insert(
uploadedFiles.map(f => ({ ...f, submission_id: sub.id }))
uploadedFiles.map(f => ({ ...f, submission_id: currentPrimary.id }))
);
if (filesError) throw new Error(`File records failed: ${filesError.message}`);
}
await supabase.from('tasks').update({ current_version: newVersion }).eq('id', id);
setTask(t => ({ ...t, current_version: newVersion }));
await supabase.from('tasks').update({ status: 'client_review' }).eq('id', id);
setTask(t => ({ ...t, status: 'client_review' }));
const { data: subs } = await supabase
.from('submissions')
@@ -578,7 +571,7 @@ export default function TaskDetail() {
setWorkForm({ files: [], description: '' });
setShowWorkUpload(false);
setNotification(`✓ Work uploaded — ${uploadedFiles.length} file${uploadedFiles.length !== 1 ? 's' : ''} added.`);
setNotification(`✓ Work submitted — ${uploadedFiles.length} file${uploadedFiles.length !== 1 ? 's' : ''} uploaded. Awaiting team review.`);
} catch (err) {
setNotification(`✗ Error: ${err.message}`);
} finally {
@@ -858,10 +851,8 @@ export default function TaskDetail() {
{!isExternal && !showSendForm && (
<button className="btn btn-success btn-sm" onClick={handleOpenSendForm}> Send to Client</button>
)}
{isExternal && (
<button className="btn btn-primary btn-sm" onClick={() => setShowWorkUpload(s => !s)} disabled={saving}>
{showWorkUpload ? 'Cancel Upload' : '⬆ Upload Work'}
</button>
{isExternal && !showSendForm && (
<button className="btn btn-success btn-sm" onClick={handleOpenSendForm}> Send to Client</button>
)}
<button className="btn btn-outline btn-sm" onClick={handleOnHold} disabled={saving}> Put On Hold</button>
</>
@@ -874,7 +865,10 @@ export default function TaskDetail() {
{!isExternal && !showSendForm && (
<button className="btn btn-success btn-sm" onClick={handleOpenSendForm}> Add Files / Resend</button>
)}
{canTeamApprove ? (
{isExternal && !showSendForm && (
<button className="btn btn-outline btn-sm" onClick={handleOpenSendForm}>📎 Add Files</button>
)}
{canApprove ? (
<button className="btn btn-success btn-sm" onClick={handleTeamApprove} disabled={saving}> Approve Request</button>
) : (
<div style={{ padding: '10px 14px', background: 'var(--bg)', borderRadius: 8, fontSize: 13, color: '#7c3aed', fontWeight: 500 }}>
@@ -1092,11 +1086,11 @@ export default function TaskDetail() {
</div>
</div>
{showWorkUpload && isExternal && (
{showWorkUpload && (
<div className="card" style={{ marginBottom: 24, border: '1px solid var(--accent)', borderRadius: 12 }}>
<div className="card-title">Upload Work Files</div>
<div className="card-title">Submit Finished Work</div>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 16 }}>
Upload your completed files. The team will review and send to the client.
Upload your completed files. This will mark the task as ready for team review.
</p>
<form onSubmit={handleWorkUpload}>
<div className="form-group">
@@ -1156,7 +1150,7 @@ export default function TaskDetail() {
</div>
<div className="action-buttons">
<button type="submit" className="btn btn-primary" disabled={workForm.files.length === 0 || saving}>
{saving ? 'Uploading...' : `Upload${workForm.files.length > 0 ? ` (${workForm.files.length} file${workForm.files.length !== 1 ? 's' : ''})` : ''}`}
{saving ? 'Submitting...' : `Submit${workForm.files.length > 0 ? ` (${workForm.files.length} file${workForm.files.length !== 1 ? 's' : ''})` : ''}`}
</button>
<button type="button" className="btn btn-outline" onClick={() => { setShowWorkUpload(false); setWorkForm({ files: [], description: '' }); }}>Cancel</button>
</div>
@@ -0,0 +1,2 @@
alter table public.profiles
add column if not exists brand_book_rate numeric(10,2) not null default 60;
@@ -0,0 +1,72 @@
-- Sub-created invoices submitted to team for payment
create table public.subcontractor_invoices (
id uuid default gen_random_uuid() primary key,
profile_id uuid references public.profiles(id) on delete cascade not null,
invoice_number text not null,
status text not null default 'draft' check (status in ('draft', 'submitted', 'paid')),
notes text not null default '',
submitted_at timestamptz,
paid_at timestamptz,
created_at timestamptz default now() not null,
updated_at timestamptz default now() not null
);
create table public.subcontractor_invoice_items (
id uuid default gen_random_uuid() primary key,
invoice_id uuid references public.subcontractor_invoices(id) on delete cascade not null,
task_id uuid references public.tasks(id) on delete set null,
description text not null,
quantity numeric(10,2) not null default 1,
unit_price numeric(10,2) not null default 0,
sort_order integer not null default 0,
created_at timestamptz default now() not null
);
alter table public.subcontractor_invoices enable row level security;
alter table public.subcontractor_invoice_items enable row level security;
-- Team: full access
create policy "Team all subcontractor_invoices" on public.subcontractor_invoices
for all using (get_my_role() = 'team') with check (get_my_role() = 'team');
-- Subs: read own
create policy "Sub select own invoices" on public.subcontractor_invoices
for select using (profile_id = auth.uid() and get_my_role() = 'external');
-- Subs: create own
create policy "Sub insert own invoices" on public.subcontractor_invoices
for insert with check (profile_id = auth.uid() and get_my_role() = 'external');
-- Subs: update own non-paid (submit draft, etc.)
create policy "Sub update own non-paid invoices" on public.subcontractor_invoices
for update using (profile_id = auth.uid() and get_my_role() = 'external' and status != 'paid');
-- Subs: delete own drafts only
create policy "Sub delete own draft invoices" on public.subcontractor_invoices
for delete using (profile_id = auth.uid() and get_my_role() = 'external' and status = 'draft');
-- Team: full access to items
create policy "Team all sub invoice items" on public.subcontractor_invoice_items
for all using (get_my_role() = 'team') with check (get_my_role() = 'team');
-- Subs: read items on own invoices
create policy "Sub read own invoice items" on public.subcontractor_invoice_items
for select using (
invoice_id in (select id from public.subcontractor_invoices where profile_id = auth.uid())
);
-- Subs: manage items on own draft invoices only
create policy "Sub insert draft invoice items" on public.subcontractor_invoice_items
for insert with check (
invoice_id in (select id from public.subcontractor_invoices where profile_id = auth.uid() and status = 'draft')
);
create policy "Sub update draft invoice items" on public.subcontractor_invoice_items
for update using (
invoice_id in (select id from public.subcontractor_invoices where profile_id = auth.uid() and status = 'draft')
);
create policy "Sub delete draft invoice items" on public.subcontractor_invoice_items
for delete using (
invoice_id in (select id from public.subcontractor_invoices where profile_id = auth.uid() and status = 'draft')
);
@@ -0,0 +1,9 @@
create or replace function public.get_next_sub_invoice_number()
returns text
language sql
security definer
set search_path = public
as $$
select 'INVSUB-' || extract(year from now())::text || '-' || lpad((count(*) + 1)::text, 3, '0')
from public.subcontractor_invoices;
$$;
@@ -0,0 +1,13 @@
-- Allow external users to insert/update deliveries (upsert) and insert delivery_files
create policy "External inserts deliveries" on public.deliveries
for insert with check (get_my_role() = 'external');
create policy "External updates deliveries" on public.deliveries
for update using (get_my_role() = 'external');
create policy "External inserts delivery_files" on public.delivery_files
for insert with check (get_my_role() = 'external');
-- Allow external users to upload to deliveries storage bucket
create policy "External inserts deliveries storage" on storage.objects
for insert to authenticated with check (bucket_id = 'deliveries' and get_my_role() = 'external');
@@ -0,0 +1,8 @@
-- Fix: items insert was blocked when invoice status = 'submitted' at creation time.
-- Allow insert on own invoices regardless of status (ownership check is sufficient).
drop policy if exists "Sub insert draft invoice items" on public.subcontractor_invoice_items;
create policy "Sub insert own invoice items" on public.subcontractor_invoice_items
for insert with check (
invoice_id in (select id from public.subcontractor_invoices where profile_id = auth.uid())
);
@@ -0,0 +1,5 @@
-- Allow subs to delete their own draft or submitted invoices (not paid)
drop policy if exists "Sub delete own draft invoices" on public.subcontractor_invoices;
create policy "Sub delete own unpaid invoices" on public.subcontractor_invoices
for delete using (profile_id = auth.uid() and get_my_role() = 'external' and status != 'paid');