Add Project Files section and show company name for external users on project detail
This commit is contained in:
@@ -55,7 +55,15 @@
|
|||||||
"Bash(npx vercel@latest --prod)",
|
"Bash(npx vercel@latest --prod)",
|
||||||
"Bash(git add *)",
|
"Bash(git add *)",
|
||||||
"Bash(git commit -m ' *)",
|
"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 *)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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') {
|
if (req.method === 'POST' && action === 'move') {
|
||||||
const srcPath = req.body?.srcPath;
|
const srcPath = req.body?.srcPath;
|
||||||
const dstDir = req.body?.dstDir;
|
const dstDir = req.body?.dstDir;
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ const FileSharing = lazy(() => import('./pages/team/FileSharing'));
|
|||||||
const FourgePasswords = lazy(() => import('./pages/team/FourgePasswords'));
|
const FourgePasswords = lazy(() => import('./pages/team/FourgePasswords'));
|
||||||
const ExternalMyRequests = lazy(() => import('./pages/external/MyRequests'));
|
const ExternalMyRequests = lazy(() => import('./pages/external/MyRequests'));
|
||||||
const MyPurchaseOrders = lazy(() => import('./pages/external/MyPurchaseOrders'));
|
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 ClientDashboard = lazy(() => import('./pages/client/ClientDashboard'));
|
||||||
const MyCompany = lazy(() => import('./pages/client/MyCompany'));
|
const MyCompany = lazy(() => import('./pages/client/MyCompany'));
|
||||||
const MyRequests = lazy(() => import('./pages/client/MyRequests'));
|
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="/server-status" element={<ProtectedRoute role="team"><ServerStatus /></ProtectedRoute>} />
|
||||||
<Route path="/assigned-requests" element={<ProtectedRoute role="external"><ExternalMyRequests /></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-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>} />
|
<Route path="/settings" element={<ProtectedRoute><Settings /></ProtectedRoute>} />
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,8 @@ function ExternalNav({ onNav }) {
|
|||||||
const links = [
|
const links = [
|
||||||
{ to: '/dashboard', label: 'Dashboard' },
|
{ to: '/dashboard', label: 'Dashboard' },
|
||||||
{ to: '/assigned-requests', label: 'Requests' },
|
{ 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: '/file-sharing', label: 'File Sharing' },
|
||||||
{ to: '/survey-maker', label: 'Survey Maker' },
|
{ to: '/survey-maker', label: 'Survey Maker' },
|
||||||
{ to: '/brand-book', label: 'Brand Book Maker' },
|
{ to: '/brand-book', label: 'Brand Book Maker' },
|
||||||
@@ -74,7 +75,7 @@ function ExternalNav({ onNav }) {
|
|||||||
<div className="sidebar-section">
|
<div className="sidebar-section">
|
||||||
{links.map(({ to, label }, index) => (
|
{links.map(({ to, label }, index) => (
|
||||||
<div key={to}>
|
<div key={to}>
|
||||||
{index === 2 && (
|
{index === 4 && (
|
||||||
<>
|
<>
|
||||||
<div style={{ height: 1, margin: '10px 12px', background: 'var(--border)' }} />
|
<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)' }}>
|
<div style={{ padding: '0 12px 8px', fontSize: 11, fontWeight: 700, letterSpacing: 0.8, textTransform: 'uppercase', color: 'var(--text-muted)' }}>
|
||||||
|
|||||||
+29
-1
@@ -519,6 +519,35 @@ body {
|
|||||||
position: relative;
|
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 {
|
.file-browser-dragging {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
@@ -973,7 +1002,6 @@ select option { background: #222; color: #fff; }
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
margin-top: 18px;
|
|
||||||
}
|
}
|
||||||
.request-toolbar-actions,
|
.request-toolbar-actions,
|
||||||
.request-filter-row {
|
.request-filter-row {
|
||||||
|
|||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import Layout from '../../components/Layout';
|
||||||
|
import { supabase } from '../../lib/supabase';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
|
||||||
|
export default function ExternalProjects() {
|
||||||
|
const { currentUser } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [projects, setProjects] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentUser?.id) { setLoading(false); return; }
|
||||||
|
supabase
|
||||||
|
.from('projects')
|
||||||
|
.select('id, name, status, company:companies(name)')
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.then(({ data, error: err }) => {
|
||||||
|
if (err) setError(err.message);
|
||||||
|
else setProjects(data || []);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, [currentUser?.id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<div className="page-title">Projects</div>
|
||||||
|
<div className="page-subtitle">All projects you are assigned to.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="card" style={{ color: 'var(--danger)', marginBottom: 16 }}>{error}</div>}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p>
|
||||||
|
) : projects.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<h3>No projects yet</h3>
|
||||||
|
<p>Projects will appear here once the team assigns you to one.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="table-wrapper">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Project</th>
|
||||||
|
<th>Client</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{projects.map(p => (
|
||||||
|
<tr key={p.id} style={{ cursor: 'pointer' }} onClick={() => navigate(`/projects/${p.id}`)}>
|
||||||
|
<td style={{ fontWeight: 600 }}>{p.name}</td>
|
||||||
|
<td style={{ color: 'var(--text-muted)' }}>{p.company?.name || '—'}</td>
|
||||||
|
<td style={{ textTransform: 'capitalize', color: 'var(--text-muted)' }}>{p.status || 'Active'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
+282
@@ -0,0 +1,282 @@
|
|||||||
|
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import Layout from '../../components/Layout';
|
||||||
|
import LoadingButton from '../../components/LoadingButton';
|
||||||
|
import { supabase } from '../../lib/supabase';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
|
||||||
|
const INVOICE_TODAY = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
function newItem(description = '', unit_price = '', quantity = 1, taskId = null, isRevision = false) {
|
||||||
|
return { id: crypto.randomUUID(), description, unit_price, quantity, task_id: taskId, is_revision: isRevision };
|
||||||
|
}
|
||||||
|
|
||||||
|
function genNumber(count) {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
return `INVSUB-${year}-${String(count + 1).padStart(3, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MyInvoiceCreate() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { currentUser } = useAuth();
|
||||||
|
|
||||||
|
const [invoiceNumber, setInvoiceNumber] = useState('');
|
||||||
|
const [completedTasks, setCompletedTasks] = useState([]);
|
||||||
|
const [loadingTasks, setLoadingTasks] = useState(true);
|
||||||
|
const [items, setItems] = useState([newItem()]);
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const dragItem = useRef(null);
|
||||||
|
|
||||||
|
const rate = Number(currentUser?.brand_book_rate || 60);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function load() {
|
||||||
|
const [{ data: tasks }, { data: existingInvoices }, { data: nextNum }] = await Promise.all([
|
||||||
|
supabase
|
||||||
|
.from('tasks')
|
||||||
|
.select('id, title, current_version, project:projects(name)')
|
||||||
|
.eq('status', 'client_approved')
|
||||||
|
.order('title'),
|
||||||
|
supabase
|
||||||
|
.from('subcontractor_invoices')
|
||||||
|
.select('id, status, items:subcontractor_invoice_items(task_id)')
|
||||||
|
.in('status', ['submitted', 'paid']),
|
||||||
|
supabase.rpc('get_next_sub_invoice_number'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setInvoiceNumber(nextNum || genNumber(0));
|
||||||
|
|
||||||
|
const invoicedTaskIds = new Set(
|
||||||
|
(existingInvoices || []).flatMap(inv => (inv.items || []).map(i => i.task_id).filter(Boolean))
|
||||||
|
);
|
||||||
|
setCompletedTasks((tasks || []).filter(t => !invoicedTaskIds.has(t.id)));
|
||||||
|
setLoadingTasks(false);
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addedTaskIds = useMemo(() => new Set(items.map(i => i.task_id).filter(Boolean)), [items]);
|
||||||
|
|
||||||
|
const addTask = (task) => {
|
||||||
|
if (addedTaskIds.has(task.id)) return;
|
||||||
|
const isRevision = (task.current_version || 0) > 0;
|
||||||
|
const desc = task.project?.name ? `${task.project.name} • ${task.title}` : task.title;
|
||||||
|
const price = isRevision ? 30 : rate;
|
||||||
|
setItems(prev => {
|
||||||
|
if (prev.length === 1 && !prev[0].description && !prev[0].unit_price) {
|
||||||
|
return [newItem(desc, price, 1, task.id, isRevision)];
|
||||||
|
}
|
||||||
|
return [...prev, newItem(desc, price, 1, task.id, isRevision)];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateItem = (id, field, value) => {
|
||||||
|
setItems(prev => prev.map(item => item.id === id ? { ...item, [field]: value } : item));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeItem = (id) => setItems(prev => prev.filter(item => item.id !== id));
|
||||||
|
|
||||||
|
const handleDrop = (targetIndex) => {
|
||||||
|
if (dragItem.current === null || dragItem.current === targetIndex) { dragItem.current = null; return; }
|
||||||
|
setItems(prev => {
|
||||||
|
const next = [...prev];
|
||||||
|
const [moved] = next.splice(dragItem.current, 1);
|
||||||
|
next.splice(targetIndex, 0, moved);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
dragItem.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const total = items.reduce((s, i) => s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0), 0);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
const valid = items.filter(i => String(i.description).trim());
|
||||||
|
if (!valid.length) { setError('Add at least one line item.'); return; }
|
||||||
|
setSaving(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const { data: inv, error: invErr } = await supabase
|
||||||
|
.from('subcontractor_invoices')
|
||||||
|
.insert({
|
||||||
|
profile_id: currentUser.id,
|
||||||
|
invoice_number: invoiceNumber.trim(),
|
||||||
|
status: 'submitted',
|
||||||
|
notes: notes.trim(),
|
||||||
|
submitted_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
if (invErr) throw invErr;
|
||||||
|
|
||||||
|
const { error: itemsErr } = await supabase.from('subcontractor_invoice_items').insert(
|
||||||
|
valid.map((item, idx) => ({
|
||||||
|
invoice_id: inv.id,
|
||||||
|
task_id: item.task_id || null,
|
||||||
|
description: String(item.description).trim(),
|
||||||
|
quantity: Number(item.quantity) || 1,
|
||||||
|
unit_price: Number(item.unit_price) || 0,
|
||||||
|
sort_order: idx,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
if (itemsErr) throw itemsErr;
|
||||||
|
|
||||||
|
navigate(`/my-invoices-sub/${inv.id}`);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<button className="back-link" onClick={() => navigate('/my-invoices-sub')}>← Back to Invoices</button>
|
||||||
|
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<div className="page-title">New Invoice</div>
|
||||||
|
<div className="page-subtitle">
|
||||||
|
Invoice date: {new Date(INVOICE_TODAY).toLocaleDateString()} · {invoiceNumber}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="notification notification-info" style={{ marginBottom: 16 }}>{error}</div>}
|
||||||
|
|
||||||
|
<div className="card" style={{ marginBottom: 24 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||||
|
<div className="card-title" style={{ marginBottom: 0 }}>Completed Tasks</div>
|
||||||
|
{completedTasks.length > 0 && !loadingTasks && (
|
||||||
|
<button
|
||||||
|
className="btn btn-outline btn-sm"
|
||||||
|
onClick={() => completedTasks.forEach(task => { if (!addedTaskIds.has(task.id)) addTask(task); })}
|
||||||
|
>
|
||||||
|
+ Add All
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{loadingTasks ? (
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading...</p>
|
||||||
|
) : completedTasks.length === 0 ? (
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>No completed tasks available to invoice.</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
{completedTasks.map(task => {
|
||||||
|
const isRevision = (task.current_version || 0) > 0;
|
||||||
|
const price = isRevision ? 30 : rate;
|
||||||
|
const alreadyAdded = addedTaskIds.has(task.id);
|
||||||
|
return (
|
||||||
|
<div key={task.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 6, border: '1px solid var(--border)' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600 }}>
|
||||||
|
{task.project?.name ? `${task.project.name} • ` : ''}{task.title}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||||
|
{isRevision ? 'Revision' : 'New'} · ${price.toFixed(2)}{isRevision ? '/hr' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className={`btn btn-sm ${alreadyAdded ? 'btn-outline' : 'btn-primary'}`}
|
||||||
|
onClick={() => !alreadyAdded && addTask(task)}
|
||||||
|
disabled={alreadyAdded}
|
||||||
|
>
|
||||||
|
{alreadyAdded ? 'Added' : '+ Add'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ marginBottom: 24 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||||
|
<div className="card-title" style={{ marginBottom: 0 }}>Line Items</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 80px 120px 120px 40px', gap: 8, marginBottom: 8 }}>
|
||||||
|
{['', 'Type', 'Description', 'Qty / Hrs', 'Rate', 'Total', ''].map((h, i) => (
|
||||||
|
<div key={i} style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', textAlign: i > 3 ? 'right' : 'left' }}>{h}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
onDragOver={e => e.preventDefault()}
|
||||||
|
onDrop={() => handleDrop(index)}
|
||||||
|
style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 80px 120px 120px 40px', gap: 8, alignItems: 'center' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
draggable
|
||||||
|
onDragStart={e => { dragItem.current = index; e.dataTransfer.effectAllowed = 'move'; }}
|
||||||
|
style={{ cursor: 'grab', color: 'var(--text-muted)', fontSize: 14, textAlign: 'center', userSelect: 'none' }}
|
||||||
|
>⠿</div>
|
||||||
|
<span className={`badge ${item.is_revision ? 'badge-client_revision' : 'badge-initial'}`}>
|
||||||
|
{item.is_revision ? 'Revision' : 'New'}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Description..."
|
||||||
|
value={item.description}
|
||||||
|
onChange={e => updateItem(item.id, 'description', e.target.value)}
|
||||||
|
style={{ margin: 0 }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0.5"
|
||||||
|
step="0.5"
|
||||||
|
value={item.quantity}
|
||||||
|
onChange={e => updateItem(item.id, 'quantity', e.target.value)}
|
||||||
|
style={{ margin: 0, textAlign: 'center' }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="0.00"
|
||||||
|
value={item.unit_price}
|
||||||
|
onChange={e => updateItem(item.id, 'unit_price', e.target.value)}
|
||||||
|
style={{ margin: 0, textAlign: 'right' }}
|
||||||
|
/>
|
||||||
|
<div style={{ textAlign: 'right', fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', paddingRight: 4 }}>
|
||||||
|
${((Number(item.quantity) || 0) * (Number(item.unit_price) || 0)).toFixed(2)}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => removeItem(item.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--danger)', fontSize: 16, padding: 4 }}>✕</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="btn btn-outline btn-sm" style={{ marginTop: 12 }} onClick={() => setItems(prev => [...prev, newItem()])}>
|
||||||
|
+ Add Line Item
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 20, paddingTop: 16, borderTop: '1px solid var(--border)' }}>
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>Total</div>
|
||||||
|
<div style={{ fontSize: 26, fontWeight: 700, color: 'var(--accent)' }}>${total.toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ marginBottom: 24 }}>
|
||||||
|
<div className="card-title">Notes</div>
|
||||||
|
<textarea
|
||||||
|
placeholder="Additional notes for the team..."
|
||||||
|
value={notes}
|
||||||
|
onChange={e => setNotes(e.target.value)}
|
||||||
|
style={{ minHeight: 80 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="action-buttons">
|
||||||
|
<LoadingButton className="btn btn-primary" onClick={handleSubmit} loading={saving} loadingText="Submitting...">
|
||||||
|
Submit Invoice
|
||||||
|
</LoadingButton>
|
||||||
|
<button className="btn btn-outline" onClick={() => navigate('/my-invoices-sub')} disabled={saving}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
+265
@@ -0,0 +1,265 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import Layout from '../../components/Layout';
|
||||||
|
import LoadingButton from '../../components/LoadingButton';
|
||||||
|
import { supabase } from '../../lib/supabase';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
import { generateSubcontractorPOPDF } from '../../lib/invoice';
|
||||||
|
|
||||||
|
const STATUS_COLOR = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
|
||||||
|
const STATUS_LABEL = { draft: 'Draft', submitted: 'Submitted', paid: 'Paid' };
|
||||||
|
|
||||||
|
function fmt(val) {
|
||||||
|
return `$${Number(val || 0).toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function invoiceTotal(items) {
|
||||||
|
return (items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MyInvoiceDetail() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { currentUser } = useAuth();
|
||||||
|
|
||||||
|
const [invoice, setInvoice] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function load() {
|
||||||
|
const { data, error: err } = await supabase
|
||||||
|
.from('subcontractor_invoices')
|
||||||
|
.select('*, items:subcontractor_invoice_items(*)')
|
||||||
|
.eq('id', id)
|
||||||
|
.single();
|
||||||
|
if (err || !data) { setError('Invoice not found.'); setLoading(false); return; }
|
||||||
|
setInvoice(data);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
setSubmitting(true);
|
||||||
|
const { error: err } = await supabase
|
||||||
|
.from('subcontractor_invoices')
|
||||||
|
.update({ status: 'submitted', submitted_at: new Date().toISOString() })
|
||||||
|
.eq('id', id);
|
||||||
|
if (err) setError(err.message);
|
||||||
|
else setInvoice(i => ({ ...i, status: 'submitted', submitted_at: new Date().toISOString() }));
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!window.confirm(`Delete invoice ${invoice.invoice_number}? This cannot be undone.`)) return;
|
||||||
|
setDeleting(true);
|
||||||
|
const { error: err } = await supabase.from('subcontractor_invoices').delete().eq('id', id);
|
||||||
|
if (err) { setError(err.message); setDeleting(false); return; }
|
||||||
|
navigate('/my-invoices-sub');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDownload() {
|
||||||
|
setDownloading(true);
|
||||||
|
try {
|
||||||
|
const total = invoiceTotal(invoice.items);
|
||||||
|
await generateSubcontractorPOPDF({
|
||||||
|
po_number: invoice.invoice_number,
|
||||||
|
status: 'paid',
|
||||||
|
profile: { name: currentUser?.name, email: currentUser?.email },
|
||||||
|
project: { name: 'Subcontractor Invoice', company: { name: 'Fourge Branding' } },
|
||||||
|
date: invoice.created_at?.split('T')[0],
|
||||||
|
due_date: invoice.paid_at?.split('T')[0] || invoice.created_at?.split('T')[0],
|
||||||
|
amount: total,
|
||||||
|
description: 'Payment received for completed subcontractor work.',
|
||||||
|
notes: invoice.notes,
|
||||||
|
items: (invoice.items || []).map((item, idx) => ({
|
||||||
|
description: item.description,
|
||||||
|
amount: Number(item.unit_price || 0) * Number(item.quantity || 1),
|
||||||
|
sort_order: item.sort_order ?? idx,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
setDownloading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||||
|
if (!invoice) return <Layout><p style={{ padding: 24 }}>{error || 'Invoice not found.'}</p></Layout>;
|
||||||
|
|
||||||
|
const total = invoiceTotal(invoice.items);
|
||||||
|
const sortedItems = [...(invoice.items || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<button className="back-link" onClick={() => navigate('/my-invoices-sub')}>← Back to Invoices</button>
|
||||||
|
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<div className="page-title">{invoice.invoice_number}</div>
|
||||||
|
<div className="page-subtitle">Fourge Branding</div>
|
||||||
|
</div>
|
||||||
|
<div className="action-buttons">
|
||||||
|
<span className={`badge badge-${STATUS_COLOR[invoice.status]}`} style={{ fontSize: 13, padding: '6px 14px' }}>
|
||||||
|
{STATUS_LABEL[invoice.status]}
|
||||||
|
</span>
|
||||||
|
{invoice.status === 'paid' && (
|
||||||
|
<LoadingButton
|
||||||
|
className="btn btn-primary"
|
||||||
|
loading={downloading}
|
||||||
|
loadingText="Generating PDF..."
|
||||||
|
disabled={downloading}
|
||||||
|
onClick={handleDownload}
|
||||||
|
>
|
||||||
|
Download Receipt
|
||||||
|
</LoadingButton>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="notification notification-info" style={{ marginBottom: 16 }}>{error}</div>}
|
||||||
|
|
||||||
|
<div className="grid-2" style={{ marginBottom: 24 }}>
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-title">From</div>
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 700 }}>{currentUser?.name || 'Subcontractor'}</div>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 4 }}>{currentUser?.email}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-title">Invoice Details</div>
|
||||||
|
<div className="detail-grid" style={{ marginBottom: 0 }}>
|
||||||
|
<div className="detail-item">
|
||||||
|
<label>Invoice #</label>
|
||||||
|
<p style={{ fontWeight: 700 }}>{invoice.invoice_number}</p>
|
||||||
|
</div>
|
||||||
|
<div className="detail-item">
|
||||||
|
<label>Created</label>
|
||||||
|
<p>{new Date(invoice.created_at).toLocaleDateString()}</p>
|
||||||
|
</div>
|
||||||
|
<div className="detail-item">
|
||||||
|
<label>Terms</label>
|
||||||
|
<p>NET 30 from client payment</p>
|
||||||
|
</div>
|
||||||
|
<div className="detail-item">
|
||||||
|
<label>Status</label>
|
||||||
|
<p>
|
||||||
|
<span className={`badge badge-${STATUS_COLOR[invoice.status]}`}>
|
||||||
|
{STATUS_LABEL[invoice.status]}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{invoice.submitted_at && (
|
||||||
|
<div className="detail-item">
|
||||||
|
<label>Submitted</label>
|
||||||
|
<p>{new Date(invoice.submitted_at).toLocaleDateString()}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="detail-item">
|
||||||
|
<label>Total</label>
|
||||||
|
<p style={{ fontSize: 18, fontWeight: 700, color: 'var(--accent)' }}>{fmt(total)}</p>
|
||||||
|
</div>
|
||||||
|
{invoice.paid_at && (
|
||||||
|
<div className="detail-item">
|
||||||
|
<label>Paid On</label>
|
||||||
|
<p style={{ color: 'var(--success, #16a34a)', fontWeight: 600 }}>
|
||||||
|
{new Date(invoice.paid_at).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ marginBottom: 24 }}>
|
||||||
|
<div className="card-title">Line Items</div>
|
||||||
|
<div className="table-wrapper" style={{ border: 'none' }}>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: 100 }}>Type</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th style={{ textAlign: 'center' }}>Qty / Hrs</th>
|
||||||
|
<th style={{ textAlign: 'right' }}>Rate</th>
|
||||||
|
<th style={{ textAlign: 'right' }}>Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sortedItems.map(item => (
|
||||||
|
<tr key={item.id}>
|
||||||
|
<td>
|
||||||
|
<span className={`badge ${item.task_id ? 'badge-in_progress' : 'badge-initial'}`}>
|
||||||
|
{item.task_id ? 'Task' : 'Other'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{item.description}</td>
|
||||||
|
<td style={{ textAlign: 'center' }}>{item.quantity}</td>
|
||||||
|
<td style={{ textAlign: 'right' }}>{fmt(item.unit_price)}</td>
|
||||||
|
<td style={{ textAlign: 'right', fontWeight: 700 }}>
|
||||||
|
{fmt(Number(item.unit_price) * Number(item.quantity || 1))}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '16px 16px 0', borderTop: '1px solid var(--border)', marginTop: 8 }}>
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>Total</div>
|
||||||
|
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--accent)' }}>{fmt(total)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{invoice.notes && (
|
||||||
|
<div className="card" style={{ marginBottom: 24 }}>
|
||||||
|
<div className="card-title">Notes</div>
|
||||||
|
<p style={{ fontSize: 14, color: 'var(--text-secondary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{invoice.notes}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-title">Actions</div>
|
||||||
|
<div className="action-buttons">
|
||||||
|
{invoice.status === 'draft' && (
|
||||||
|
<LoadingButton
|
||||||
|
className="btn btn-primary"
|
||||||
|
loading={submitting}
|
||||||
|
loadingText="Submitting..."
|
||||||
|
disabled={submitting || deleting}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
Submit to Team
|
||||||
|
</LoadingButton>
|
||||||
|
)}
|
||||||
|
{invoice.status === 'paid' && (
|
||||||
|
<LoadingButton
|
||||||
|
className="btn btn-success"
|
||||||
|
loading={downloading}
|
||||||
|
loadingText="Generating PDF..."
|
||||||
|
disabled={downloading}
|
||||||
|
onClick={handleDownload}
|
||||||
|
>
|
||||||
|
Download Receipt
|
||||||
|
</LoadingButton>
|
||||||
|
)}
|
||||||
|
{invoice.status !== 'paid' && (
|
||||||
|
<LoadingButton
|
||||||
|
className="btn btn-danger"
|
||||||
|
loading={deleting}
|
||||||
|
loadingText="Deleting..."
|
||||||
|
disabled={submitting || deleting}
|
||||||
|
onClick={handleDelete}
|
||||||
|
>
|
||||||
|
Delete Invoice
|
||||||
|
</LoadingButton>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
Vendored
+93
@@ -0,0 +1,93 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import Layout from '../../components/Layout';
|
||||||
|
import StatusBadge from '../../components/StatusBadge';
|
||||||
|
import { supabase } from '../../lib/supabase';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
||||||
|
|
||||||
|
const STATUS_BADGE = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
|
||||||
|
const STATUS_LABEL = { draft: 'Draft', submitted: 'Submitted', paid: 'Paid' };
|
||||||
|
|
||||||
|
function fmt(val) {
|
||||||
|
return `$${Number(val || 0).toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function invoiceTotal(items) {
|
||||||
|
return (items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MyInvoices() {
|
||||||
|
const { currentUser } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const cacheKey = `ext-invoices:${currentUser?.id}`;
|
||||||
|
const cached = readPageCache(cacheKey, 3 * 60_000);
|
||||||
|
const [invoices, setInvoices] = useState(() => cached || []);
|
||||||
|
const [loading, setLoading] = useState(() => !cached);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentUser?.id) { setLoading(false); return; }
|
||||||
|
supabase
|
||||||
|
.from('subcontractor_invoices')
|
||||||
|
.select('*, items:subcontractor_invoice_items(*)')
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.then(({ data, error: err }) => {
|
||||||
|
if (err) setError(err.message);
|
||||||
|
else { setInvoices(data || []); writePageCache(cacheKey, data || []); }
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, [currentUser?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<div className="page-title">Invoices</div>
|
||||||
|
<div className="page-subtitle">Submit invoices to Fourge Branding for your completed work.</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 4 }}>Payment terms: NET 30 from the date Fourge receives payment from the client.</div>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary" onClick={() => navigate('/my-invoices-sub/new')} disabled={loading}>
|
||||||
|
+ New Invoice
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="notification notification-info" style={{ marginBottom: 16 }}>{error}</div>}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="empty-state">Loading invoices...</div>
|
||||||
|
) : invoices.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<h3>No invoices yet</h3>
|
||||||
|
<p>Create your first invoice to get paid for your completed work.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="table-wrapper">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Invoice #</th>
|
||||||
|
<th>Submitted</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th style={{ textAlign: 'right' }}>Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{invoices.map(inv => {
|
||||||
|
const total = invoiceTotal(inv.items);
|
||||||
|
return (
|
||||||
|
<tr key={inv.id} style={{ cursor: 'pointer' }} onClick={() => navigate(`/my-invoices-sub/${inv.id}`)}>
|
||||||
|
<td style={{ fontWeight: 700 }}>{inv.invoice_number}</td>
|
||||||
|
<td>{inv.submitted_at ? new Date(inv.submitted_at).toLocaleDateString() : <span style={{ color: 'var(--text-muted)' }}>—</span>}</td>
|
||||||
|
<td><StatusBadge status={STATUS_BADGE[inv.status]} label={STATUS_LABEL[inv.status]} /></td>
|
||||||
|
<td style={{ textAlign: 'right', fontWeight: 700 }}>{fmt(total)}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
Vendored
+184
-165
@@ -1,60 +1,47 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import Layout from '../../components/Layout';
|
import Layout from '../../components/Layout';
|
||||||
import StatusBadge from '../../components/StatusBadge';
|
import StatusBadge from '../../components/StatusBadge';
|
||||||
import { supabase } from '../../lib/supabase';
|
import { supabase } from '../../lib/supabase';
|
||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import { withTimeout } from '../../lib/withTimeout';
|
import { withTimeout } from '../../lib/withTimeout';
|
||||||
|
import { getCurrentVersionForTask, getDeadlineSourceSubmission } from '../../lib/taskDeadlines';
|
||||||
const STATUS_ORDER = ['in_progress', 'not_started', 'client_review', 'needs_revision', 'on_hold', 'client_approved'];
|
import { formatDateOnly } from '../../lib/dates';
|
||||||
|
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
||||||
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 || ''));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ExternalMyRequests() {
|
export default function ExternalMyRequests() {
|
||||||
const { currentUser } = useAuth();
|
const { currentUser } = useAuth();
|
||||||
const [projects, setProjects] = useState([]);
|
const navigate = useNavigate();
|
||||||
const [tasks, setTasks] = useState([]);
|
const cacheKey = `ext-requests:${currentUser?.id}`;
|
||||||
const [poItemsByTaskId, setPoItemsByTaskId] = useState({});
|
const cached = readPageCache(cacheKey, 3 * 60_000);
|
||||||
const [loading, setLoading] = useState(true);
|
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 [error, setError] = useState('');
|
||||||
|
const [activeTab, setActiveTab] = useState('active');
|
||||||
|
const [filterProject, setFilterProject] = useState('');
|
||||||
|
const [filterRequester, setFilterRequester] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
if (!currentUser?.id) { setLoading(false); return; }
|
if (!currentUser?.id) { setLoading(false); return; }
|
||||||
try {
|
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 [
|
const [
|
||||||
{ data: projectData, error: projectError },
|
{ data: projectData, error: projectError },
|
||||||
{ data: taskData, error: taskError },
|
{ data: taskData, error: taskError },
|
||||||
{ data: poItems, error: poError },
|
{ data: subData, error: subError },
|
||||||
|
{ data: paidItems },
|
||||||
] = await withTimeout(
|
] = await withTimeout(
|
||||||
Promise.all([
|
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
|
supabase
|
||||||
.from('projects')
|
.from('subcontractor_invoice_items')
|
||||||
.select('id, name, company:companies(name)')
|
.select('task_id, invoice:subcontractor_invoices!inner(status)')
|
||||||
.order('created_at', { ascending: false }),
|
.eq('subcontractor_invoices.status', 'paid'),
|
||||||
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
|
|
||||||
)
|
|
||||||
`),
|
|
||||||
]),
|
]),
|
||||||
15000,
|
15000,
|
||||||
'External requests load'
|
'External requests load'
|
||||||
@@ -62,19 +49,19 @@ export default function ExternalMyRequests() {
|
|||||||
|
|
||||||
if (projectError) throw projectError;
|
if (projectError) throw projectError;
|
||||||
if (taskError) throw taskError;
|
if (taskError) throw taskError;
|
||||||
if (poError) throw poError;
|
if (subError) throw subError;
|
||||||
|
|
||||||
// Build a map of task_id → PO item for quick lookup
|
const paid = new Set(
|
||||||
const byTask = {};
|
(paidItems || [])
|
||||||
(poItems || []).forEach(item => {
|
.filter(item => item.invoice?.status === 'paid' && item.task_id)
|
||||||
if (item.task_id && item.po?.status !== 'cancelled') {
|
.map(item => item.task_id)
|
||||||
byTask[item.task_id] = item;
|
);
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
setProjects(projectData || []);
|
setProjects(projectData || []);
|
||||||
setTasks(taskData || []);
|
setTasks(taskData || []);
|
||||||
setPoItemsByTaskId(byTask);
|
setSubmissions(subData || []);
|
||||||
|
setPaidTaskIds(paid);
|
||||||
|
writePageCache(cacheKey, { projects: projectData || [], tasks: taskData || [], submissions: subData || [], paidTaskIds: [...paid] });
|
||||||
setError('');
|
setError('');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('External requests load failed:', err);
|
console.error('External requests load failed:', err);
|
||||||
@@ -86,47 +73,74 @@ export default function ExternalMyRequests() {
|
|||||||
load();
|
load();
|
||||||
}, [currentUser?.id]);
|
}, [currentUser?.id]);
|
||||||
|
|
||||||
const activeTasks = useMemo(() => tasks.filter(t => t.status !== 'client_approved'), [tasks]);
|
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||||
const completedTasks = useMemo(() => tasks.filter(t => t.status === 'client_approved'), [tasks]);
|
|
||||||
|
|
||||||
function renderTask(task) {
|
const isFullyClosedTask = (task) => task?.status === 'client_approved' && paidTaskIds.has(task.id);
|
||||||
const po = poItemsByTaskId[task.id];
|
|
||||||
const dueDate = po?.po?.due_date
|
const latestTaskGroups = tasks.map(task => {
|
||||||
? new Date(po.po.due_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
const taskSubs = submissions.filter(s => s.task_id === task.id);
|
||||||
: null;
|
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 (
|
return (
|
||||||
<Link
|
<tr key={task.id} onClick={() => navigate(`/tasks/${task.id}`)} style={{ cursor: 'pointer' }}>
|
||||||
key={task.id}
|
<td style={{ fontWeight: 600 }}>{project?.name || 'No project'}</td>
|
||||||
to={`/tasks/${task.id}`}
|
<td style={{ fontWeight: 600 }}>
|
||||||
className="interactive-row"
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
style={{
|
<span>{task?.title || primary.service_type}</span>
|
||||||
display: 'grid',
|
{primary.is_hot ? <span className="badge badge-needs_revision">Hot</span> : null}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
</td>
|
||||||
{po && <span style={{ color: 'var(--accent)', fontWeight: 600 }}>{po.po?.po_number}</span>}
|
<td>{revisionLabel}</td>
|
||||||
{po?.amount != null && <span>${Number(po.amount).toFixed(2)}</span>}
|
<td>{primary.service_type || 'Request'}</td>
|
||||||
{dueDate && <span>Due {dueDate}</span>}
|
<td>{deadline}</td>
|
||||||
</div>
|
<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>
|
||||||
</div>
|
</tr>
|
||||||
<StatusBadge status={task.status} />
|
|
||||||
</Link>
|
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
@@ -137,103 +151,108 @@ export default function ExternalMyRequests() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="stats-grid" style={{ marginBottom: 24 }}>
|
{(projectNames.length > 0 || requesterNames.length > 0) && (
|
||||||
<div className="stat-card stat-card-highlight">
|
<div className="card request-toolbar-card" style={{ marginBottom: 16 }}>
|
||||||
<div className="stat-value">{tasks.length}</div>
|
<div className="request-toolbar-grid">
|
||||||
<div className="stat-label">Total Tasks</div>
|
{projectNames.length > 0 && (
|
||||||
</div>
|
<div className="request-toolbar-section">
|
||||||
<div className="stat-card">
|
<div className="card-title" style={{ marginBottom: 10 }}>Filter By Project</div>
|
||||||
<div className="stat-value">{activeTasks.length}</div>
|
<div className="request-filter-row">
|
||||||
<div className="stat-label">Active</div>
|
<button className={`btn btn-sm ${!filterProject ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterProject('')}>All</button>
|
||||||
</div>
|
{projectNames.map(p => (
|
||||||
<div className="stat-card">
|
<button
|
||||||
<div className="stat-value" style={{ color: activeTasks.filter(t => t.status === 'client_review').length > 0 ? 'var(--accent)' : undefined }}>
|
key={p.id}
|
||||||
{activeTasks.filter(t => t.status === 'client_review').length}
|
className={`btn btn-sm ${filterProject === p.id ? 'btn-primary' : 'btn-outline'}`}
|
||||||
</div>
|
onClick={() => setFilterProject(f => f === p.id ? '' : p.id)}
|
||||||
<div className="stat-label">Awaiting Review</div>
|
>
|
||||||
</div>
|
{p.name}
|
||||||
<div className="stat-card">
|
</button>
|
||||||
<div className="stat-value">{completedTasks.length}</div>
|
))}
|
||||||
<div className="stat-label">Completed</div>
|
|
||||||
</div>
|
</div>
|
||||||
</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 ? (
|
{submissions.length === 0 ? (
|
||||||
<p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p>
|
|
||||||
) : error ? (
|
|
||||||
<div className="card" style={{ color: 'var(--danger)' }}>{error}</div>
|
|
||||||
) : tasks.length === 0 ? (
|
|
||||||
<div className="empty-state">
|
<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>
|
<p>Tasks will appear here once Fourge assigns you to a project.</p>
|
||||||
</div>
|
</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>
|
||||||
<div style={{ fontWeight: 700, fontSize: 14 }}>{project.name}</div>
|
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||||
{project.company?.name && (
|
<div className="card-title" style={{ marginBottom: 0, display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{project.company.name}</div>
|
{tabList.map((tab, index) => (
|
||||||
)}
|
<span key={tab.id} style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||||||
</div>
|
{index > 0 && <span style={{ color: 'var(--text-muted)' }}>|</span>}
|
||||||
<div style={{ display: 'flex', gap: 8, fontSize: 12, color: 'var(--text-muted)' }}>
|
<button
|
||||||
{active.length > 0 && <span style={{ fontWeight: 600, color: 'var(--accent)' }}>{active.length} active</span>}
|
type="button"
|
||||||
{done.length > 0 && <span>{done.length} done</span>}
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Active tasks */}
|
{currentGroups.length === 0 ? (
|
||||||
{active.length > 0 && (
|
<div className="empty-state" style={{ padding: '28px 20px' }}>
|
||||||
<div>
|
<h3>No {tabList.find(t => t.id === activeTab)?.label.toLowerCase()} requests</h3>
|
||||||
{active.map(task => renderTask(task))}
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Completed tasks — collapsed by default */}
|
{error && <div className="card" style={{ color: 'var(--danger)', marginTop: 16 }}>{error}</div>}
|
||||||
{done.length > 0 && (
|
|
||||||
<CompletedGroup tasks={done} renderTask={renderTask} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Layout>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import LoadingButton from '../../components/LoadingButton';
|
|||||||
import { supabase } from '../../lib/supabase';
|
import { supabase } from '../../lib/supabase';
|
||||||
import { generateBrandBookEditorPDF } from '../../lib/brandBookEditor';
|
import { generateBrandBookEditorPDF } from '../../lib/brandBookEditor';
|
||||||
import { cleanupBrandBookStorage } from '../../lib/deleteHelpers';
|
import { cleanupBrandBookStorage } from '../../lib/deleteHelpers';
|
||||||
import { archiveBrandBooksToLocalZip, restoreBrandBookArchive } from '../../lib/archiveHelpers';
|
|
||||||
|
|
||||||
const BUCKET = 'brand-books';
|
const BUCKET = 'brand-books';
|
||||||
|
|
||||||
@@ -156,12 +155,6 @@ export default function BrandBook() {
|
|||||||
const sitePhotosRef = useRef();
|
const sitePhotosRef = useRef();
|
||||||
const projectLogoRef = useRef();
|
const projectLogoRef = useRef();
|
||||||
const clientLogoRef = 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('');
|
const [filterCompany, setFilterCompany] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -452,73 +445,6 @@ export default function BrandBook() {
|
|||||||
fetchBooks();
|
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 () => {
|
const handleSave = async () => {
|
||||||
if (!bookInfo.clientName.trim()) {
|
if (!bookInfo.clientName.trim()) {
|
||||||
setNotification({ type: 'error', msg: 'Please select a client.' });
|
setNotification({ type: 'error', msg: 'Please select a client.' });
|
||||||
@@ -848,48 +774,10 @@ export default function BrandBook() {
|
|||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<button className="btn btn-primary btn-sm" onClick={handleNew}>+ New Brand Book</button>
|
<button className="btn btn-primary btn-sm" onClick={handleNew}>+ New Brand Book</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{companyNames.length > 0 && (
|
{companyNames.length > 0 && (
|
||||||
<div className="request-toolbar-grid">
|
<div className="card request-toolbar-card" style={{ marginBottom: 16 }}>
|
||||||
<div className="request-toolbar-section">
|
<div className="request-toolbar-section">
|
||||||
<div className="card-title" style={{ marginBottom: 10 }}>Filter By Company</div>
|
<div className="card-title" style={{ marginBottom: 10 }}>Filter By Company</div>
|
||||||
<div className="request-filter-row">
|
<div className="request-filter-row">
|
||||||
@@ -912,7 +800,6 @@ export default function BrandBook() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{loadingBooks ? (
|
{loadingBooks ? (
|
||||||
<p style={{ padding: '24px 0', color: 'var(--text-muted)' }}>Loading...</p>
|
<p style={{ padding: '24px 0', color: 'var(--text-muted)' }}>Loading...</p>
|
||||||
@@ -927,7 +814,6 @@ export default function BrandBook() {
|
|||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th></th>
|
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Revision</th>
|
<th>Revision</th>
|
||||||
<th>Sign Count</th>
|
<th>Sign Count</th>
|
||||||
@@ -946,14 +832,6 @@ export default function BrandBook() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={book.id} onClick={() => handleLoad(book)} style={{ cursor: 'pointer' }}>
|
<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 style={{ fontWeight: 600 }}>{book.project_name || 'Brand Book'}</td>
|
||||||
<td>{`R${String(book.revision || '01').padStart(2, '0')}`}</td>
|
<td>{`R${String(book.revision || '01').padStart(2, '0')}`}</td>
|
||||||
<td>{signCount}</td>
|
<td>{signCount}</td>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import Layout from '../../components/Layout';
|
import Layout from '../../components/Layout';
|
||||||
import { supabase } from '../../lib/supabase';
|
import { supabase } from '../../lib/supabase';
|
||||||
import { deleteCompanyData } from '../../lib/deleteHelpers';
|
import { deleteCompanyData } from '../../lib/deleteHelpers';
|
||||||
import { restoreCompanyArchive } from '../../lib/archiveHelpers';
|
|
||||||
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
||||||
import { syncSeafileFolders } from '../../lib/seafileFolders';
|
import { syncSeafileFolders } from '../../lib/seafileFolders';
|
||||||
|
|
||||||
@@ -30,11 +29,8 @@ export default function Companies() {
|
|||||||
const [editingUserId, setEditingUserId] = useState(null);
|
const [editingUserId, setEditingUserId] = useState(null);
|
||||||
const [editUserVal, setEditUserVal] = useState('');
|
const [editUserVal, setEditUserVal] = useState('');
|
||||||
const [deletingUserId, setDeletingUserId] = useState(null);
|
const [deletingUserId, setDeletingUserId] = useState(null);
|
||||||
const [restoringArchive, setRestoringArchive] = useState(false);
|
|
||||||
const [restoreStatus, setRestoreStatus] = useState('');
|
|
||||||
const [filterCompany, setFilterCompany] = useState('');
|
const [filterCompany, setFilterCompany] = useState('');
|
||||||
const [activeTab, setActiveTab] = useState('companies');
|
const [activeTab, setActiveTab] = useState('companies');
|
||||||
const restoreInputRef = useRef(null);
|
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const [{ data: co }, { data: prof }, { data: memberships }] = await Promise.all([
|
const [{ data: co }, { data: prof }, { data: memberships }] = await Promise.all([
|
||||||
@@ -78,36 +74,6 @@ export default function Companies() {
|
|||||||
load();
|
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) => {
|
const handleEditUserSave = async (userId) => {
|
||||||
if (!editUserVal.trim()) return;
|
if (!editUserVal.trim()) return;
|
||||||
await supabase.from('profiles').update({ name: editUserVal.trim() }).eq('id', userId);
|
await supabase.from('profiles').update({ name: editUserVal.trim() }).eq('id', userId);
|
||||||
@@ -188,13 +154,6 @@ export default function Companies() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input
|
|
||||||
ref={restoreInputRef}
|
|
||||||
type="file"
|
|
||||||
accept=".zip,application/zip"
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
onChange={handleRestoreArchive}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="stats-grid" style={{ marginBottom: 24 }}>
|
<div className="stats-grid" style={{ marginBottom: 24 }}>
|
||||||
@@ -373,22 +332,6 @@ export default function Companies() {
|
|||||||
|
|
||||||
{activeTab === '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 ? (
|
{companies.length === 0 ? (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<h3>No companies yet</h3>
|
<h3>No companies yet</h3>
|
||||||
|
|||||||
@@ -264,9 +264,61 @@ function buildRevisionPeople(submissions, tasks, roleFilter) {
|
|||||||
}, new Map()).values()];
|
}, 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 activeTasks = tasks.filter(t => t.status !== 'client_approved');
|
||||||
const completedTasks = 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 (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
@@ -277,6 +329,27 @@ function ExternalDashboard({ currentUser, projects, tasks }) {
|
|||||||
</div>
|
</div>
|
||||||
</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 ? (
|
{projects.length === 0 ? (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<div className="empty-state-icon">📋</div>
|
<div className="empty-state-icon">📋</div>
|
||||||
@@ -327,26 +400,30 @@ export default function Dashboard() {
|
|||||||
const [tasks, setTasks] = useState(() => cached?.tasks || []);
|
const [tasks, setTasks] = useState(() => cached?.tasks || []);
|
||||||
const [projects, setProjects] = useState(() => cached?.projects || []);
|
const [projects, setProjects] = useState(() => cached?.projects || []);
|
||||||
const [submissions, setSubmissions] = useState(() => cached?.submissions || []);
|
const [submissions, setSubmissions] = useState(() => cached?.submissions || []);
|
||||||
|
const [pos, setPos] = useState(() => cached?.pos || []);
|
||||||
|
const [externalProfiles, setExternalProfiles] = useState(() => cached?.externalProfiles || []);
|
||||||
const [loading, setLoading] = useState(() => !cached);
|
const [loading, setLoading] = useState(() => !cached);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
if (isExternal) {
|
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('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('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');
|
]), 12000, 'Dashboard load');
|
||||||
setProjects(p || []);
|
setProjects(p || []);
|
||||||
setTasks(t || []);
|
setTasks(t || []);
|
||||||
|
setPos(posData || []);
|
||||||
setSubmissions([]);
|
setSubmissions([]);
|
||||||
writePageCache(cacheKey, { projects: p || [], tasks: t || [], submissions: [] });
|
writePageCache(cacheKey, { projects: p || [], tasks: t || [], submissions: [], pos: posData || [], externalProfiles: [] });
|
||||||
} else {
|
} else {
|
||||||
const [{ data: t }, { data: p }, { data: submissions }, { data: profiles }] = await withTimeout(Promise.all([
|
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('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('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('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');
|
]), 12000, 'Dashboard load');
|
||||||
|
|
||||||
const roleByProfileId = new Map((profiles || []).map(profile => [profile.id, profile.role]));
|
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,
|
delivery_sender_role: roleByProfileName.get(submission.delivery?.sent_by) || null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const externals = (profiles || []).filter(pr => pr.role === 'external');
|
||||||
setTasks(tasksWithDeadlines);
|
setTasks(tasksWithDeadlines);
|
||||||
setProjects(p || []);
|
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);
|
setSubmissions(submissionsWithRole);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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 (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||||
|
|
||||||
if (isExternal) {
|
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');
|
const activeTasks = tasks.filter(t => t.status !== 'client_approved');
|
||||||
@@ -481,6 +560,8 @@ export default function Dashboard() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SubcontractorRates externals={externalProfiles} />
|
||||||
|
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+168
-42
@@ -41,6 +41,11 @@ export default function FileSharing() {
|
|||||||
const [folderName, setFolderName] = useState('');
|
const [folderName, setFolderName] = useState('');
|
||||||
const [showFolderInput, setShowFolderInput] = useState(false);
|
const [showFolderInput, setShowFolderInput] = useState(false);
|
||||||
const [movingEntry, setMovingEntry] = useState(null);
|
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 fileInputRef = useRef(null);
|
||||||
|
|
||||||
const breadcrumbs = useMemo(() => pathParts(currentPath), [currentPath]);
|
const breadcrumbs = useMemo(() => pathParts(currentPath), [currentPath]);
|
||||||
@@ -68,10 +73,7 @@ export default function FileSharing() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({ action: 'list', path });
|
||||||
action: 'list',
|
|
||||||
path,
|
|
||||||
});
|
|
||||||
if (invalidateUsage) params.set('invalidateUsage', '1');
|
if (invalidateUsage) params.set('invalidateUsage', '1');
|
||||||
|
|
||||||
const data = await apiFetch(`/api/seafile?${params.toString()}`);
|
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.');
|
if (data.configured === false) setError(data.error || 'Seafile is not configured yet.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
setUsageBytes(0);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
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 uploadFiles = async (files) => {
|
||||||
const selected = Array.from(files || []);
|
const selected = Array.from(files || []);
|
||||||
if (!selected.length) return;
|
if (!selected.length) return;
|
||||||
|
|
||||||
setWorking('upload');
|
setWorking('upload');
|
||||||
|
setUploadProgress(0);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const data = await apiFetch('/api/seafile?action=upload-link', {
|
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('parent_dir', data.parentDir);
|
||||||
formData.append('replace', '0');
|
formData.append('replace', '0');
|
||||||
|
|
||||||
const uploadResponse = await fetch(`${data.uploadLink}${data.uploadLink.includes('?') ? '&' : '?'}ret-json=1`, {
|
await new Promise((resolve, reject) => {
|
||||||
method: 'POST',
|
const xhr = new XMLHttpRequest();
|
||||||
body: formData,
|
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);
|
await loadFiles(currentPath);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
setWorking('');
|
setWorking('');
|
||||||
|
setUploadProgress(null);
|
||||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
setDragging(false);
|
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) => {
|
const moveEntry = async (entry, targetFolderPath) => {
|
||||||
setWorking(`move:${entry.path}`);
|
setWorking(`move:${entry.path}`);
|
||||||
setError('');
|
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 (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
@@ -260,6 +334,15 @@ export default function FileSharing() {
|
|||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={handleDrop}
|
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 && (
|
{dragging && (
|
||||||
<div className="file-drop-overlay">
|
<div className="file-drop-overlay">
|
||||||
<div className="file-drop-panel">
|
<div className="file-drop-panel">
|
||||||
@@ -325,6 +408,12 @@ export default function FileSharing() {
|
|||||||
|
|
||||||
{error && <div className="notification notification-info">{error}</div>}
|
{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">
|
<div className="file-list">
|
||||||
{currentPath !== '/' && (
|
{currentPath !== '/' && (
|
||||||
<button type="button" className="file-row file-row-button" onClick={() => loadFiles(parentPath)} disabled={loading || Boolean(working)}>
|
<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>
|
</div>
|
||||||
) : entries.map(entry => {
|
) : entries.map(entry => {
|
||||||
const isMoving = movingEntry?.path === entry.path;
|
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 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 (
|
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>
|
<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)}>
|
<button type="button" className="file-name file-name-button" onClick={() => openFolder(entry)} disabled={Boolean(working)}>
|
||||||
{entry.name}
|
{entry.name}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<span className="file-name">{entry.name}</span>
|
<span className="file-name">{entry.name}</span>
|
||||||
)}
|
)}
|
||||||
|
{!isRenaming && (
|
||||||
|
<>
|
||||||
<span className="file-meta">{formatBytes(entry.aggregateSize ?? entry.size)}</span>
|
<span className="file-meta">{formatBytes(entry.aggregateSize ?? entry.size)}</span>
|
||||||
<span className="file-meta">{formatDate(entry.mtime)}</span>
|
<span className="file-meta">{formatDate(entry.mtime)}</span>
|
||||||
<span className="file-row-actions">
|
<span className="file-row-actions">
|
||||||
@@ -393,8 +514,11 @@ export default function FileSharing() {
|
|||||||
Download
|
Download
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
)}
|
)}
|
||||||
|
<button type="button" className="btn btn-outline btn-sm" disabled={Boolean(working)} onClick={() => startRename(entry)}>
|
||||||
|
Rename
|
||||||
|
</button>
|
||||||
{targetFolders.length > 0 && (
|
{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
|
Move
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -404,6 +528,8 @@ export default function FileSharing() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
+160
-83
@@ -4,7 +4,7 @@ import Layout from '../../components/Layout';
|
|||||||
import { supabase } from '../../lib/supabase';
|
import { supabase } from '../../lib/supabase';
|
||||||
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
||||||
import { exportCPAPackage, generateSubcontractorPOPDF } from '../../lib/invoice';
|
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 CATEGORIES = ['Software', 'Contractor', 'Advertising', 'Office', 'Travel', 'Meals', 'Equipment', 'Other'];
|
||||||
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
|
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 [subcontractorLoading, setSubcontractorLoading] = useState(true);
|
||||||
const [subcontractorError, setSubcontractorError] = useState('');
|
const [subcontractorError, setSubcontractorError] = useState('');
|
||||||
const [selectedSubcontractorPOId, setSelectedSubcontractorPOId] = 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(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
@@ -113,6 +118,19 @@ export default function Invoices() {
|
|||||||
loadExpenses();
|
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) => {
|
const startEditExpense = (expense) => {
|
||||||
setEditingExpenseId(expense.id);
|
setEditingExpenseId(expense.id);
|
||||||
setNewExpense({
|
setNewExpense({
|
||||||
@@ -274,6 +292,56 @@ export default function Invoices() {
|
|||||||
updateSubcontractorPO(po, { status: 'paid', paid_at: paidAt }, 'Failed to mark as paid');
|
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) => {
|
const handleReopenSubcontractorPO = async (po) => {
|
||||||
updateSubcontractorPO(po, { status: 'draft', paid_at: null, cancelled_at: null }, 'Failed to reopen 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' }}>
|
<div className="card-title" style={{ marginBottom: 0, display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||||
{[
|
{[
|
||||||
{ id: 'invoices', label: 'Invoices' },
|
{ id: 'invoices', label: 'Invoices' },
|
||||||
{ id: 'subcontractor-po', label: 'Subcontractor PO' },
|
{ id: 'sub-invoices', label: 'Subcontractor Invoices' },
|
||||||
{ id: 'expenses', label: 'Expenses' },
|
{ id: 'expenses', label: 'Expenses' },
|
||||||
].map((tab, index) => (
|
].map((tab, index) => (
|
||||||
<span key={tab.id} style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
<span key={tab.id} style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||||||
@@ -462,9 +530,7 @@ export default function Invoices() {
|
|||||||
{activeTab === 'invoices' && (
|
{activeTab === 'invoices' && (
|
||||||
<button className="btn btn-primary btn-sm" onClick={() => navigate('/invoices/new')}>+ New Invoice</button>
|
<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>
|
</div>
|
||||||
|
|
||||||
{activeTab === 'invoices' && (
|
{activeTab === 'invoices' && (
|
||||||
@@ -774,113 +840,124 @@ export default function Invoices() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'subcontractor-po' && (
|
{activeTab === 'sub-invoices' && (
|
||||||
<div>
|
|
||||||
<div>
|
<div>
|
||||||
<div className="card" style={{ marginBottom: 16 }}>
|
<div className="card" style={{ marginBottom: 16 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12 }}>
|
<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 }}>
|
<div style={{ display: 'flex', gap: 12, fontSize: 13, fontWeight: 700 }}>
|
||||||
<span style={{ color: 'var(--accent)' }}>${totalPayableSubcontractors.toFixed(2)} payable</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>${totalPaidSubcontractors.toFixed(2)} paid</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>
|
||||||
</div>
|
</div>
|
||||||
|
{subInvoicesLoading ? (
|
||||||
{subcontractorLoading ? (
|
|
||||||
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Loading...</p>
|
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Loading...</p>
|
||||||
) : subcontractorError ? (
|
) : subInvoicesError ? (
|
||||||
<p style={{ color: 'var(--danger)', fontSize: 13 }}>{subcontractorError}</p>
|
<p style={{ color: 'var(--danger)', fontSize: 13 }}>{subInvoicesError}</p>
|
||||||
) : subcontractorPOs.length === 0 ? (
|
) : subInvoices.length === 0 ? (
|
||||||
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>No subcontractor POs yet.</p>
|
<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 }}>
|
<div className="table-wrapper" style={{ marginTop: 0 }}>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>PO #</th>
|
<th>Invoice #</th>
|
||||||
<th>Subcontractor</th>
|
<th>Subcontractor</th>
|
||||||
<th>Date</th>
|
<th>Submitted</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th style={{ textAlign: 'right' }}>Amount</th>
|
<th style={{ textAlign: 'right' }}>Total</th>
|
||||||
|
<th style={{ textAlign: 'right' }}>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{subcontractorPOs.map(po => (
|
{subInvoices.map(inv => {
|
||||||
<tr key={po.id} onClick={() => navigate(`/subcontractor-pos/${po.id}`)} style={{ cursor: 'pointer' }}>
|
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>
|
<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>
|
||||||
<td>
|
<td>{inv.submitted_at ? new Date(inv.submitted_at).toLocaleDateString() : <span style={{ color: 'var(--text-muted)' }}>—</span>}</td>
|
||||||
<div style={{ fontWeight: 600 }}>{po.profile?.name || 'External'}</div>
|
<td><span className={`badge badge-${subInvoiceStatusColor[inv.status] || 'not_started'}`} style={{ textTransform: 'capitalize' }}>{inv.status}</span></td>
|
||||||
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>{po.profile?.email || '—'}</div>
|
<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>
|
||||||
<td>
|
</tr>
|
||||||
<div>{new Date(po.paid_at || po.date).toLocaleDateString()}</div>
|
{isExpanded && (
|
||||||
{po.due_date && <div style={{ color: 'var(--text-muted)', fontSize: 12 }}>Due {new Date(po.due_date).toLocaleDateString()}</div>}
|
<tr key={`${inv.id}-detail`}>
|
||||||
</td>
|
<td colSpan={6} style={{ background: 'var(--bg)', padding: '12px 16px' }}>
|
||||||
<td>
|
{inv.items?.length > 0 ? (
|
||||||
<span className={`badge badge-${poStatusColor[po.status] || 'not_started'}`} style={{ textTransform: 'capitalize' }}>
|
<table style={{ width: '100%', fontSize: 13 }}>
|
||||||
{poStatusLabel[po.status] || po.status}
|
<thead>
|
||||||
</span>
|
<tr>
|
||||||
</td>
|
<th style={{ textAlign: 'left', fontWeight: 600, paddingBottom: 6 }}>Description</th>
|
||||||
<td style={{ textAlign: 'right', fontWeight: 700 }}>${Number(po.amount).toFixed(2)}</td>
|
<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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||||
import Layout from '../../components/Layout';
|
import Layout from '../../components/Layout';
|
||||||
import StatusBadge from '../../components/StatusBadge';
|
import StatusBadge from '../../components/StatusBadge';
|
||||||
@@ -35,6 +35,10 @@ export default function ProjectDetail() {
|
|||||||
const [externalProfiles, setExternalProfiles] = useState([]);
|
const [externalProfiles, setExternalProfiles] = useState([]);
|
||||||
const [selectedExternal, setSelectedExternal] = useState('');
|
const [selectedExternal, setSelectedExternal] = useState('');
|
||||||
const [addingMember, setAddingMember] = useState(false);
|
const [addingMember, setAddingMember] = useState(false);
|
||||||
|
|
||||||
|
const [projectFiles, setProjectFiles] = useState([]);
|
||||||
|
const [uploadingFile, setUploadingFile] = useState(false);
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
const requesterOptions = [
|
const requesterOptions = [
|
||||||
...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)`, email: currentUser.email || '' }] : []),
|
...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)`, email: currentUser.email || '' }] : []),
|
||||||
...companyUsers.filter(user => user.id !== currentUser?.id),
|
...companyUsers.filter(user => user.id !== currentUser?.id),
|
||||||
@@ -47,18 +51,20 @@ export default function ProjectDetail() {
|
|||||||
if (!p) return;
|
if (!p) return;
|
||||||
setProject(p);
|
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('companies').select('*').eq('id', p.company_id).single(),
|
||||||
supabase.from('tasks').select('*').eq('project_id', id).order('submitted_at', { ascending: false }),
|
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('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('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('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);
|
setCompany(co);
|
||||||
setTasks(t || []);
|
setTasks(t || []);
|
||||||
setCompanyUsers(users || []);
|
setCompanyUsers(users || []);
|
||||||
setMembers(pm || []);
|
setMembers(pm || []);
|
||||||
setExternalProfiles(ext || []);
|
setExternalProfiles(ext || []);
|
||||||
|
setProjectFiles(pf || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('ProjectDetail load failed:', error);
|
console.error('ProjectDetail load failed:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -152,6 +158,38 @@ export default function ProjectDetail() {
|
|||||||
setMembers(prev => prev.filter(m => m.profile_id !== profileId));
|
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 (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||||
if (!project) return <Layout><p>Project not found.</p></Layout>;
|
if (!project) return <Layout><p>Project not found.</p></Layout>;
|
||||||
|
|
||||||
@@ -185,11 +223,13 @@ export default function ProjectDetail() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="page-subtitle">
|
<div className="page-subtitle">
|
||||||
{!isExternal && company && (
|
{company && (
|
||||||
<>
|
<>
|
||||||
<Link to={`/companies/${company.id}`} style={{ color: 'var(--accent)' }}>
|
{isExternal ? (
|
||||||
{company.name}
|
<span style={{ color: 'var(--text-secondary)' }}>{company.name}</span>
|
||||||
</Link>
|
) : (
|
||||||
|
<Link to={`/companies/${company.id}`} style={{ color: 'var(--accent)' }}>{company.name}</Link>
|
||||||
|
)}
|
||||||
{' · '}
|
{' · '}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -313,6 +353,53 @@ export default function ProjectDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<div className="card-title">Jobs</div>
|
||||||
{tasks.length === 0 ? (
|
{tasks.length === 0 ? (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
|
|||||||
+5
-134
@@ -1,12 +1,10 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import Layout from '../../components/Layout';
|
import Layout from '../../components/Layout';
|
||||||
import StatusBadge from '../../components/StatusBadge';
|
import StatusBadge from '../../components/StatusBadge';
|
||||||
import { supabase } from '../../lib/supabase';
|
import { supabase } from '../../lib/supabase';
|
||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import { serviceTypes } from '../../data/mockData';
|
import { serviceTypes } from '../../data/mockData';
|
||||||
import { cleanupTaskStorage } from '../../lib/deleteHelpers';
|
|
||||||
import { archiveCompletedJobsToLocalZip, restoreCompletedJobsArchive } from '../../lib/archiveHelpers';
|
|
||||||
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
||||||
import { withTimeout } from '../../lib/withTimeout';
|
import { withTimeout } from '../../lib/withTimeout';
|
||||||
import { getCurrentVersionForTask, getDeadlineSourceSubmission } from '../../lib/taskDeadlines';
|
import { getCurrentVersionForTask, getDeadlineSourceSubmission } from '../../lib/taskDeadlines';
|
||||||
@@ -40,12 +38,6 @@ export default function Requests() {
|
|||||||
const [addSaving, setAddSaving] = useState(false);
|
const [addSaving, setAddSaving] = useState(false);
|
||||||
const [addError, setAddError] = useState('');
|
const [addError, setAddError] = useState('');
|
||||||
const [addRequestKey, setAddRequestKey] = useState(() => crypto.randomUUID());
|
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 = [
|
const requesterOptions = [
|
||||||
...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)`, email: currentUser.email || '' }] : []),
|
...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)`, email: currentUser.email || '' }] : []),
|
||||||
...companyUsers.filter(user => user.id !== currentUser?.id),
|
...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))
|
.filter(item => item.task_id && paidInvoiceIds.has(item.invoice_id))
|
||||||
.map(item => item.task_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) {
|
} catch (error) {
|
||||||
console.error('Requests load failed:', error);
|
console.error('Requests load failed:', error);
|
||||||
setSubmissions([]);
|
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>;
|
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();
|
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 };
|
return { task, primary: deadlineSource, group: latestGroup };
|
||||||
}).filter(Boolean);
|
}).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 filteredGroups = latestTaskGroups.filter(({ task, group }) => {
|
||||||
const project = projects.find(p => p.id === task?.project_id);
|
const project = projects.find(p => p.id === task?.project_id);
|
||||||
if (filterCompany && project?.company_id !== filterCompany) return false;
|
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 clientReviewGroups = filteredGroups.filter(({ task }) => task?.status === 'client_review');
|
||||||
const completedGroups = filteredGroups.filter(({ task }) => task?.status === 'client_approved' && !isFullyClosedTask(task));
|
const completedGroups = filteredGroups.filter(({ task }) => task?.status === 'client_approved' && !isFullyClosedTask(task));
|
||||||
const closedGroups = filteredGroups.filter(({ task }) => 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 project = projects.find(p => p.id === task?.project_id);
|
||||||
const company = companies.find(co => co.id === project?.company_id);
|
const company = companies.find(co => co.id === project?.company_id);
|
||||||
const isCompleted = task?.status === 'client_approved';
|
const isCompleted = task?.status === 'client_approved';
|
||||||
@@ -306,17 +227,6 @@ export default function Requests() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={task.id} onClick={() => task && navigate(`/tasks/${task.id}`)} style={{ cursor: task ? 'pointer' : 'default' }}>
|
<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 }}>{project?.name || 'No project'}</td>
|
||||||
<td style={{ fontWeight: 600 }}>
|
<td style={{ fontWeight: 600 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
@@ -349,13 +259,6 @@ export default function Requests() {
|
|||||||
{showAddForm ? 'Cancel' : '+ Add Request'}
|
{showAddForm ? 'Cancel' : '+ Add Request'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<input
|
|
||||||
ref={restoreInputRef}
|
|
||||||
type="file"
|
|
||||||
accept=".zip,application/zip"
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
onChange={handleRestoreArchive}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showAddForm && (
|
{showAddForm && (
|
||||||
@@ -463,38 +366,8 @@ export default function Requests() {
|
|||||||
</div>
|
</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) && (
|
{(companies.length > 0 || requesterNames.length > 0) && (
|
||||||
|
<div className="card request-toolbar-card" style={{ marginBottom: 16 }}>
|
||||||
<div className="request-toolbar-grid">
|
<div className="request-toolbar-grid">
|
||||||
{companies.length > 0 && (
|
{companies.length > 0 && (
|
||||||
<div className="request-toolbar-section">
|
<div className="request-toolbar-section">
|
||||||
@@ -513,7 +386,6 @@ export default function Requests() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{requesterNames.length > 0 && (
|
{requesterNames.length > 0 && (
|
||||||
<div className="request-toolbar-section">
|
<div className="request-toolbar-section">
|
||||||
<div className="card-title" style={{ marginBottom: 10 }}>Filter By Requester</div>
|
<div className="card-title" style={{ marginBottom: 10 }}>Filter By Requester</div>
|
||||||
@@ -532,8 +404,8 @@ export default function Requests() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{submissions.length === 0 ? (
|
{submissions.length === 0 ? (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
@@ -649,7 +521,6 @@ export default function Requests() {
|
|||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th></th>
|
|
||||||
<th>Project</th>
|
<th>Project</th>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Revision</th>
|
<th>Revision</th>
|
||||||
@@ -660,7 +531,7 @@ export default function Requests() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{completedGroups.map(group => renderRow(group, true))}
|
{completedGroups.map(group => renderRow(group))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -102,10 +102,9 @@ export default function TaskDetail() {
|
|||||||
const currentPrimarySubmission = getCurrentPrimarySubmission(submissions, getRevisionBaseline(task, submissions));
|
const currentPrimarySubmission = getCurrentPrimarySubmission(submissions, getRevisionBaseline(task, submissions));
|
||||||
const currentDelivery = currentPrimarySubmission?.delivery || null;
|
const currentDelivery = currentPrimarySubmission?.delivery || null;
|
||||||
const currentDeliveryFiles = currentDelivery?.files || [];
|
const currentDeliveryFiles = currentDelivery?.files || [];
|
||||||
const canTeamApprove =
|
const canApprove =
|
||||||
!isExternal
|
currentUser?.role === 'client'
|
||||||
&& task?.status === 'client_review'
|
&& task?.status === 'client_review';
|
||||||
&& currentPrimarySubmission?.submitted_by === currentUser?.id;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
@@ -373,9 +372,13 @@ export default function TaskDetail() {
|
|||||||
|
|
||||||
const handleTeamApprove = async () => {
|
const handleTeamApprove = async () => {
|
||||||
setSaving(true);
|
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() }));
|
setTask(t => ({ ...t, status: 'client_approved', completed_at: new Date().toISOString() }));
|
||||||
setNotification('✓ Job approved.');
|
setNotification('✓ Job approved.');
|
||||||
|
}
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -538,7 +541,8 @@ export default function TaskDetail() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
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 = [];
|
const uploadedFiles = [];
|
||||||
for (const file of workForm.files) {
|
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 });
|
if (uploaded) uploadedFiles.push({ name: file.name, storage_path: path, size: file.size });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: sub, error: subError } = await supabase.from('submissions').insert({
|
if (uploadedFiles.length > 0) {
|
||||||
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) {
|
|
||||||
const { error: filesError } = await supabase.from('submission_files').insert(
|
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}`);
|
if (filesError) throw new Error(`File records failed: ${filesError.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await supabase.from('tasks').update({ current_version: newVersion }).eq('id', id);
|
await supabase.from('tasks').update({ status: 'client_review' }).eq('id', id);
|
||||||
setTask(t => ({ ...t, current_version: newVersion }));
|
setTask(t => ({ ...t, status: 'client_review' }));
|
||||||
|
|
||||||
const { data: subs } = await supabase
|
const { data: subs } = await supabase
|
||||||
.from('submissions')
|
.from('submissions')
|
||||||
@@ -578,7 +571,7 @@ export default function TaskDetail() {
|
|||||||
|
|
||||||
setWorkForm({ files: [], description: '' });
|
setWorkForm({ files: [], description: '' });
|
||||||
setShowWorkUpload(false);
|
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) {
|
} catch (err) {
|
||||||
setNotification(`✗ Error: ${err.message}`);
|
setNotification(`✗ Error: ${err.message}`);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -858,10 +851,8 @@ export default function TaskDetail() {
|
|||||||
{!isExternal && !showSendForm && (
|
{!isExternal && !showSendForm && (
|
||||||
<button className="btn btn-success btn-sm" onClick={handleOpenSendForm}>✉️ Send to Client</button>
|
<button className="btn btn-success btn-sm" onClick={handleOpenSendForm}>✉️ Send to Client</button>
|
||||||
)}
|
)}
|
||||||
{isExternal && (
|
{isExternal && !showSendForm && (
|
||||||
<button className="btn btn-primary btn-sm" onClick={() => setShowWorkUpload(s => !s)} disabled={saving}>
|
<button className="btn btn-success btn-sm" onClick={handleOpenSendForm}>✉️ Send to Client</button>
|
||||||
{showWorkUpload ? 'Cancel Upload' : '⬆ Upload Work'}
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
<button className="btn btn-outline btn-sm" onClick={handleOnHold} disabled={saving}>⏸ Put On Hold</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 && (
|
{!isExternal && !showSendForm && (
|
||||||
<button className="btn btn-success btn-sm" onClick={handleOpenSendForm}>✉️ Add Files / Resend</button>
|
<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>
|
<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 }}>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showWorkUpload && isExternal && (
|
{showWorkUpload && (
|
||||||
<div className="card" style={{ marginBottom: 24, border: '1px solid var(--accent)', borderRadius: 12 }}>
|
<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 }}>
|
<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>
|
</p>
|
||||||
<form onSubmit={handleWorkUpload}>
|
<form onSubmit={handleWorkUpload}>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
@@ -1156,7 +1150,7 @@ export default function TaskDetail() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="action-buttons">
|
<div className="action-buttons">
|
||||||
<button type="submit" className="btn btn-primary" disabled={workForm.files.length === 0 || saving}>
|
<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>
|
||||||
<button type="button" className="btn btn-outline" onClick={() => { setShowWorkUpload(false); setWorkForm({ files: [], description: '' }); }}>Cancel</button>
|
<button type="button" className="btn btn-outline" onClick={() => { setShowWorkUpload(false); setWorkForm({ files: [], description: '' }); }}>Cancel</button>
|
||||||
</div>
|
</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');
|
||||||
Reference in New Issue
Block a user