fix: crash bugs in TeamInvoices + faster load
Bug fixes: - TeamInvoices: add useAuth import/currentUser (invoice-create crash) - TeamInvoices: setChartYear -> setExportYear (year-dropdown crash) - TeamDashboard: drop redundant setState-in-effect (cascading renders) - Companies: delete dead _UnusedClientCompanies (illegal hook calls) - annotate intentional empty PDF-fallback catches Load speed: - preconnect/dns-prefetch to Supabase origin - lazy-load heic-to in Converters: page chunk 2737KB -> 9KB - split recharts into its own 'charts' vendor chunk Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+26
-10
@@ -52,8 +52,8 @@ const TeamSubInvoiceDetail = lazy(() => import('./pages/team/TeamSubInvoiceDetai
|
||||
const SurveyMaker = lazy(() => import('./pages/SurveyMaker'));
|
||||
const BrandBook = lazy(() => import('./pages/BrandBook'));
|
||||
const Converters = lazy(() => import('./pages/Converters'));
|
||||
const FileSharing = lazy(() => import('./pages/FileSharing'));
|
||||
const TeamFourgePasswords = lazy(() => import('./pages/team/TeamFourgePasswords'));
|
||||
const TeamReports = lazy(() => import('./pages/team/TeamReports'));
|
||||
const ExternalMyPurchaseOrders = lazy(() => import('./pages/external/ExternalMyPurchaseOrders'));
|
||||
const ExternalMyInvoices = lazy(() => import('./pages/external/ExternalMyInvoices'));
|
||||
const ExternalMyInvoiceDetail = lazy(() => import('./pages/external/ExternalMyInvoiceDetail'));
|
||||
@@ -73,6 +73,16 @@ function RedirectToTask() {
|
||||
return <Navigate to={`/tasks/${id}`} replace />;
|
||||
}
|
||||
|
||||
function RedirectTeamInvoiceDetail() {
|
||||
const { id } = useParams();
|
||||
return <Navigate to={`/finances/${id}`} replace />;
|
||||
}
|
||||
|
||||
function RedirectSubsInvoiceDetail() {
|
||||
const { id } = useParams();
|
||||
return <Navigate to={`/subs-invoices/${id}`} replace />;
|
||||
}
|
||||
|
||||
function NavigateCompanyDetail() {
|
||||
const { id } = useParams();
|
||||
return <Navigate to={`/company/${id}`} replace />;
|
||||
@@ -100,25 +110,30 @@ export default function App() {
|
||||
<Route path="/tasks" element={<ProtectedRoute role={['team', 'external', 'client']}><RequestsPage /></ProtectedRoute>} />
|
||||
<Route path="/requests" element={<Navigate to="/tasks" replace />} />
|
||||
<Route path="/team-projects" element={<Navigate to="/tasks" replace />} />
|
||||
<Route path="/invoices" element={<ProtectedRoute role="team"><TeamInvoices /></ProtectedRoute>} />
|
||||
<Route path="/invoices/new" element={<ProtectedRoute role="team"><TeamCreateInvoice /></ProtectedRoute>} />
|
||||
<Route path="/finances" element={<ProtectedRoute role="team"><TeamInvoices /></ProtectedRoute>} />
|
||||
<Route path="/finances/new" element={<ProtectedRoute role="team"><TeamCreateInvoice /></ProtectedRoute>} />
|
||||
<Route path="/subcontractor-pos/new" element={<ProtectedRoute role="team"><TeamCreateSubcontractorPO /></ProtectedRoute>} />
|
||||
<Route path="/invoices/:id" element={<ProtectedRoute role="team"><TeamInvoiceDetail /></ProtectedRoute>} />
|
||||
<Route path="/finances/:id" element={<ProtectedRoute role="team"><TeamInvoiceDetail /></ProtectedRoute>} />
|
||||
<Route path="/subcontractor-pos/:id" element={<ProtectedRoute role="team"><TeamSubcontractorPODetail /></ProtectedRoute>} />
|
||||
<Route path="/sub-invoices/:id" element={<ProtectedRoute role="team"><TeamSubInvoiceDetail /></ProtectedRoute>} />
|
||||
<Route path="/invoices" element={<Navigate to="/finances" replace />} />
|
||||
<Route path="/invoices/new" element={<Navigate to="/finances/new" replace />} />
|
||||
<Route path="/invoices/:id" element={<RedirectTeamInvoiceDetail />} />
|
||||
<Route path="/survey-maker" element={<ProtectedRoute role={['team', 'external']}><SurveyMaker /></ProtectedRoute>} />
|
||||
<Route path="/brand-book" element={<ProtectedRoute role={['team', 'external']}><BrandBook /></ProtectedRoute>} />
|
||||
<Route path="/converters" element={<ProtectedRoute role={['team', 'external']}><Converters /></ProtectedRoute>} />
|
||||
<Route path="/file-sharing" element={<ProtectedRoute role={['team', 'external', 'client']}><FileSharing /></ProtectedRoute>} />
|
||||
<Route path="/file-uploads" element={<Navigate to="/file-sharing" replace />} />
|
||||
<Route path="/fourge-passwords" element={<ProtectedRoute role="team"><TeamFourgePasswords /></ProtectedRoute>} />
|
||||
<Route path="/reports" element={<ProtectedRoute role="team"><TeamReports /></ProtectedRoute>} />
|
||||
<Route path="/server-status" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/assigned-requests" element={<Navigate to="/tasks" replace />} />
|
||||
<Route path="/my-purchase-orders" element={<ProtectedRoute role="external"><ExternalMyPurchaseOrders /></ProtectedRoute>} />
|
||||
<Route path="/my-projects-sub" element={<Navigate to="/tasks" replace />} />
|
||||
<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="/subs-invoices" element={<ProtectedRoute role="external"><ExternalMyInvoices /></ProtectedRoute>} />
|
||||
<Route path="/subs-invoices/new" element={<ProtectedRoute role="external"><ExternalMyInvoiceCreate /></ProtectedRoute>} />
|
||||
<Route path="/subs-invoices/:id" element={<ProtectedRoute role="external"><ExternalMyInvoiceDetail /></ProtectedRoute>} />
|
||||
<Route path="/my-invoices-sub" element={<Navigate to="/subs-invoices" replace />} />
|
||||
<Route path="/my-invoices-sub/new" element={<Navigate to="/subs-invoices/new" replace />} />
|
||||
<Route path="/my-invoices-sub/:id" element={<RedirectSubsInvoiceDetail />} />
|
||||
|
||||
<Route path="/profile" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} />
|
||||
<Route path="/profile/:id" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} />
|
||||
@@ -130,7 +145,8 @@ export default function App() {
|
||||
<Route path="/my-requests/:id" element={<RedirectToTask />} />
|
||||
<Route path="/my-projects" element={<Navigate to="/tasks" replace />} />
|
||||
<Route path="/my-projects/:id" element={<RedirectProjectDetail />} />
|
||||
<Route path="/my-invoices" element={<ProtectedRoute role="client"><ClientMyInvoices /></ProtectedRoute>} />
|
||||
<Route path="/client-invoices" element={<ProtectedRoute role="client"><ClientMyInvoices /></ProtectedRoute>} />
|
||||
<Route path="/my-invoices" element={<Navigate to="/client-invoices" replace />} />
|
||||
<Route path="/new-request" element={<Navigate to="/tasks" replace />} />
|
||||
<Route path="/new-project" element={<Navigate to="/tasks" replace />} />
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { useId, useRef, useState } from 'react';
|
||||
|
||||
const MAX_FILES = 20;
|
||||
const MAX_SIZE_MB = 250;
|
||||
@@ -10,6 +10,7 @@ const formatSize = (bytes) => {
|
||||
};
|
||||
|
||||
export default function FileAttachment({ files, onChange }) {
|
||||
const inputId = useId();
|
||||
const [errors, setErrors] = useState([]);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const dragCounter = useRef(0);
|
||||
@@ -79,8 +80,8 @@ export default function FileAttachment({ files, onChange }) {
|
||||
transition: 'all 160ms', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<input type="file" multiple onChange={handleChange} style={{ display: 'none' }} id="req-file-upload" />
|
||||
<label htmlFor="req-file-upload" style={{ cursor: 'pointer', textTransform: 'none', letterSpacing: 'normal', fontWeight: 400 }}>
|
||||
<input type="file" multiple onChange={handleChange} style={{ display: 'none' }} id={inputId} />
|
||||
<label htmlFor={inputId} style={{ cursor: 'pointer', textTransform: 'none', letterSpacing: 'normal', fontWeight: 400 }}>
|
||||
<div style={{ fontSize: 20, marginBottom: 4 }}>{dragging ? '📂' : '📎'}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>
|
||||
{dragging
|
||||
|
||||
@@ -1,520 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import LoadingButton from './LoadingButton';
|
||||
import SortTh from './SortTh';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { popupMenuStyle } from '../lib/popupStyles';
|
||||
|
||||
const TABLE_TH = { fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px', border: 'none', background: 'transparent', verticalAlign: 'top' };
|
||||
const TABLE_TD = { padding: '5px', fontSize: 13, color: 'var(--text-primary)', border: 'none', background: 'transparent' };
|
||||
const LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' };
|
||||
const ITEM_STYLE = { display: 'flex', alignItems: 'center', gap: 8, padding: '7px 14px', fontSize: 13, cursor: 'pointer', color: 'var(--text-primary)', background: 'none', border: 'none', width: '100%', textAlign: 'left', borderRadius: 4, whiteSpace: 'nowrap' };
|
||||
const SEP = { height: 1, background: 'var(--border)', margin: '4px 0' };
|
||||
|
||||
function formatBytes(bytes) {
|
||||
const value = Number(bytes || 0);
|
||||
if (!value) return '—';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
|
||||
return `${(value / (1024 ** index)).toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
|
||||
}
|
||||
|
||||
function formatDate(dt) {
|
||||
if (!dt) return '—';
|
||||
const d = new Date(dt);
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + ' ' + d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
|
||||
}
|
||||
|
||||
function fileIconStyle(ext) {
|
||||
const e = ext.toLowerCase();
|
||||
if (['jpg','jpeg','png','gif','webp','svg','ico','bmp','tiff','avif','heic'].includes(e)) return { bg: '#16a34a', color: '#fff' };
|
||||
if (['mp4','mov','avi','mkv','webm','m4v','wmv','flv'].includes(e)) return { bg: '#7c3aed', color: '#fff' };
|
||||
if (['mp3','wav','ogg','flac','aac','m4a'].includes(e)) return { bg: '#db2777', color: '#fff' };
|
||||
if (e === 'pdf') return { bg: '#dc2626', color: '#fff' };
|
||||
if (['doc','docx','rtf'].includes(e)) return { bg: '#2563eb', color: '#fff' };
|
||||
if (['txt','md'].includes(e)) return { bg: '#64748b', color: '#fff' };
|
||||
if (['xls','xlsx','csv'].includes(e)) return { bg: '#16a34a', color: '#fff' };
|
||||
if (['ppt','pptx'].includes(e)) return { bg: '#ea580c', color: '#fff' };
|
||||
if (['zip','rar','7z','tar','gz'].includes(e)) return { bg: '#92400e', color: '#fff' };
|
||||
if (['ai','eps'].includes(e)) return { bg: '#ff6c00', color: '#fff' };
|
||||
if (['psd','psb'].includes(e)) return { bg: '#001e36', color: '#31a8ff' };
|
||||
if (['indd','idml'].includes(e)) return { bg: '#49021f', color: '#ff3366' };
|
||||
if (['fig','sketch'].includes(e)) return { bg: '#7c3aed', color: '#fff' };
|
||||
return { bg: '#475569', color: '#fff' };
|
||||
}
|
||||
|
||||
function FileIcon({ entry }) {
|
||||
if (entry.type === 'dir') {
|
||||
return (
|
||||
<div style={{ width: 24, height: 24, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const ext = (entry.name.includes('.') ? entry.name.split('.').pop() : '').toUpperCase().slice(0, 4) || 'FILE';
|
||||
const { bg, color } = fileIconStyle(ext);
|
||||
return (
|
||||
<div style={{ width: 24, height: 24, borderRadius: 4, background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<span style={{ color, fontSize: 7, fontWeight: 600, letterSpacing: 0.3, fontFamily: 'monospace' }}>{ext}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function pathParts(path) { return String(path || '/').split('/').filter(Boolean); }
|
||||
function pathTo(index, parts) { return `/${parts.slice(0, index + 1).join('/')}`; }
|
||||
function joinVirtualPath(...parts) { return ('/' + parts.join('/')).replace(/\/+/g, '/').replace(/\/$/, '') || '/'; }
|
||||
|
||||
export default function FileBrowser({ initialPath = '/', rootPath = '/' }) {
|
||||
const [currentPath, setCurrentPath] = useState(initialPath);
|
||||
const [entries, setEntries] = useState([]);
|
||||
const [parentPath, setParentPath] = useState('/');
|
||||
const [canGoUp, setCanGoUp] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [working, setWorking] = useState('');
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [readOnly, setReadOnly] = useState(false);
|
||||
const [folderName, setFolderName] = useState('');
|
||||
const [showFolderInput, setShowFolderInput] = useState(false);
|
||||
const [renamingEntry, setRenamingEntry] = useState(null);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
const [uploadProgress, setUploadProgress] = useState(null);
|
||||
const [selected, setSelected] = useState(new Set());
|
||||
const [sortKey, setSortKey] = useState('name');
|
||||
const [sortDir, setSortDir] = useState('asc');
|
||||
const [ctxMenu, setCtxMenu] = useState(null);
|
||||
const [clipboard, setClipboard] = useState(null);
|
||||
const fileInputRef = useRef(null);
|
||||
const folderInputRef = useRef(null);
|
||||
|
||||
const breadcrumbs = useMemo(() => pathParts(currentPath), [currentPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ctxMenu) return;
|
||||
const close = () => setCtxMenu(null);
|
||||
window.addEventListener('click', close);
|
||||
return () => window.removeEventListener('click', close);
|
||||
}, [ctxMenu]);
|
||||
|
||||
const apiFetch = async (url, options = {}) => {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session?.access_token) throw new Error('Session expired. Please sign in again.');
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
...(options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.error || 'File request failed.');
|
||||
return data;
|
||||
};
|
||||
|
||||
const loadFiles = async (path = currentPath) => {
|
||||
setLoading(true); setError(''); setSelected(new Set());
|
||||
try {
|
||||
const params = new URLSearchParams({ action: 'list', path });
|
||||
const data = await apiFetch(`/api/filebrowser?${params}`);
|
||||
if (data.configured === false) { setError(data.error || 'File browser not configured.'); return; }
|
||||
setEntries(data.entries || []);
|
||||
setCurrentPath(data.path || path);
|
||||
setParentPath(data.parentPath || '/');
|
||||
setCanGoUp(data.canGoUp || false);
|
||||
setReadOnly(data.readOnly || false);
|
||||
} catch (err) { setError(err.message); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
useEffect(() => { loadFiles(initialPath); }, [initialPath]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const toggleSort = (col) => {
|
||||
if (sortKey === col) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
||||
else { setSortKey(col); setSortDir(col === 'modified' ? 'desc' : 'asc'); }
|
||||
};
|
||||
|
||||
const sortedEntries = useMemo(() => {
|
||||
return [...entries].sort((a, b) => {
|
||||
const aDir = a.type === 'dir'; const bDir = b.type === 'dir';
|
||||
if (aDir !== bDir) return aDir ? -1 : 1;
|
||||
let av, bv;
|
||||
if (sortKey === 'name') { av = (a.name || '').toLowerCase(); bv = (b.name || '').toLowerCase(); }
|
||||
else if (sortKey === 'size') { av = Number(a.size || 0); bv = Number(b.size || 0); }
|
||||
else if (sortKey === 'modified') { av = new Date(a.mtime || 0).getTime(); bv = new Date(b.mtime || 0).getTime(); }
|
||||
else { av = ''; bv = ''; }
|
||||
const r = typeof av === 'string' ? av.localeCompare(bv) : (av > bv ? 1 : av < bv ? -1 : 0);
|
||||
return sortDir === 'asc' ? r : -r;
|
||||
});
|
||||
}, [entries, sortKey, sortDir]);
|
||||
|
||||
const downloadFile = async (entry) => {
|
||||
setWorking(`dl:${entry.path}`);
|
||||
try {
|
||||
const data = await apiFetch(`/api/filebrowser?action=download&path=${encodeURIComponent(entry.path)}`);
|
||||
if (data.url && data.token) {
|
||||
const sep = data.url.includes('?') ? '&' : '?';
|
||||
const a = document.createElement('a');
|
||||
a.href = `${data.url}${sep}auth=${encodeURIComponent(data.token)}`;
|
||||
a.download = entry.name; a.target = '_blank'; a.rel = 'noopener noreferrer';
|
||||
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
||||
}
|
||||
} catch (err) { setError(err.message); }
|
||||
finally { setWorking(''); }
|
||||
};
|
||||
|
||||
const downloadSelected = async () => {
|
||||
if (!selected.size) return;
|
||||
setWorking('dl-sel');
|
||||
try {
|
||||
for (const name of selected) {
|
||||
const entry = entries.find(e => e.name === name);
|
||||
if (entry && entry.type !== 'dir') await downloadFile(entry);
|
||||
}
|
||||
} finally { setWorking(''); }
|
||||
};
|
||||
|
||||
const deleteEntry = async (entry) => {
|
||||
if (!window.confirm(`Delete "${entry.name}"? This cannot be undone.`)) return;
|
||||
setWorking(`del:${entry.path}`);
|
||||
try {
|
||||
await apiFetch(`/api/filebrowser?action=delete&path=${encodeURIComponent(entry.path)}`, { method: 'DELETE' });
|
||||
await loadFiles(currentPath);
|
||||
} catch (err) { setError(err.message); }
|
||||
finally { setWorking(''); }
|
||||
};
|
||||
|
||||
const deleteSelected = async () => {
|
||||
if (!selected.size || !window.confirm(`Delete ${selected.size} item(s)?`)) return;
|
||||
for (const name of selected) {
|
||||
const entry = entries.find(e => e.name === name);
|
||||
if (!entry) continue;
|
||||
await apiFetch(`/api/filebrowser?action=delete&path=${encodeURIComponent(entry.path)}`, { method: 'DELETE' }).catch(() => {});
|
||||
}
|
||||
setSelected(new Set());
|
||||
await loadFiles(currentPath);
|
||||
};
|
||||
|
||||
const createFolder = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!folderName.trim()) return;
|
||||
setWorking('mkdir');
|
||||
try {
|
||||
await apiFetch('/api/filebrowser?action=mkdir', { method: 'POST', body: JSON.stringify({ path: currentPath, name: folderName }) });
|
||||
setFolderName(''); setShowFolderInput(false);
|
||||
await loadFiles(currentPath);
|
||||
} catch (err) { setError(err.message); }
|
||||
finally { setWorking(''); }
|
||||
};
|
||||
|
||||
const renameEntry = async (e) => {
|
||||
e.preventDefault();
|
||||
const newName = renameValue.trim();
|
||||
if (!newName || newName === renamingEntry.name) { setRenamingEntry(null); return; }
|
||||
setWorking(`ren:${renamingEntry.path}`);
|
||||
try {
|
||||
await apiFetch('/api/filebrowser?action=rename', { method: 'POST', body: JSON.stringify({ path: renamingEntry.path, name: newName }) });
|
||||
setRenamingEntry(null);
|
||||
await loadFiles(currentPath);
|
||||
} catch (err) { setError(err.message); }
|
||||
finally { setWorking(''); }
|
||||
};
|
||||
|
||||
const pasteClipboard = async () => {
|
||||
if (!clipboard) return;
|
||||
setWorking('paste');
|
||||
try {
|
||||
for (const item of clipboard.items) {
|
||||
await apiFetch('/api/filebrowser?action=' + (clipboard.mode === 'copy' ? 'copy' : 'move'), {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ srcPath: item.path, dstParentPath: currentPath }),
|
||||
});
|
||||
}
|
||||
if (clipboard.mode === 'move') setClipboard(null);
|
||||
await loadFiles(currentPath);
|
||||
} catch (err) { setError(err.message); }
|
||||
finally { setWorking(''); }
|
||||
};
|
||||
|
||||
async function uploadOneFile(vPath, file, retries = 3) {
|
||||
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||
try {
|
||||
await apiFetch(`/api/filebrowser?action=upload&path=${encodeURIComponent(vPath)}`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/octet-stream' }, body: file,
|
||||
});
|
||||
return;
|
||||
} catch (err) {
|
||||
if (attempt === retries) throw err;
|
||||
await new Promise(r => setTimeout(r, attempt * 1000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const uploadFiles = async (files) => {
|
||||
const sel = Array.from(files || []);
|
||||
if (!sel.length) return;
|
||||
setWorking('upload'); setUploadProgress(0); setError('');
|
||||
try {
|
||||
for (let i = 0; i < sel.length; i++) {
|
||||
await uploadOneFile(joinVirtualPath(currentPath, sel[i].name), sel[i]);
|
||||
setUploadProgress(Math.round(((i + 1) / sel.length) * 100));
|
||||
}
|
||||
} catch (err) { setError(err.message); }
|
||||
finally { setWorking(''); setUploadProgress(null); if (fileInputRef.current) fileInputRef.current.value = ''; setDragging(false); await loadFiles(currentPath); }
|
||||
};
|
||||
|
||||
const uploadFolder = async (files) => {
|
||||
const sel = Array.from(files || []).filter(f => f.webkitRelativePath);
|
||||
if (!sel.length) return;
|
||||
setWorking('upload'); setUploadProgress(0); setError('');
|
||||
try {
|
||||
const dirsNeeded = new Set();
|
||||
for (const file of sel) {
|
||||
const parts = file.webkitRelativePath.split('/').slice(0, -1);
|
||||
for (let i = 1; i <= parts.length; i++) dirsNeeded.add(parts.slice(0, i).join('/'));
|
||||
}
|
||||
for (const dir of [...dirsNeeded].sort((a, b) => a.split('/').length - b.split('/').length)) {
|
||||
await apiFetch('/api/filebrowser?action=mkdir', { method: 'POST', body: JSON.stringify({ path: currentPath, name: dir.split('/').pop() }) }).catch(() => {});
|
||||
}
|
||||
for (let i = 0; i < sel.length; i++) {
|
||||
await uploadOneFile(joinVirtualPath(currentPath, sel[i].webkitRelativePath), sel[i]);
|
||||
setUploadProgress(Math.round(((i + 1) / sel.length) * 100));
|
||||
}
|
||||
} catch (err) { setError(err.message); }
|
||||
finally { setWorking(''); setUploadProgress(null); if (folderInputRef.current) folderInputRef.current.value = ''; await loadFiles(currentPath); }
|
||||
};
|
||||
|
||||
const handleDragEnter = (e) => { e.preventDefault(); if (!readOnly) setDragging(true); };
|
||||
const handleDragOver = (e) => { e.preventDefault(); if (!readOnly) { e.dataTransfer.dropEffect = 'copy'; setDragging(true); } };
|
||||
const handleDragLeave = (e) => { e.preventDefault(); if (!e.currentTarget.contains(e.relatedTarget)) setDragging(false); };
|
||||
const handleDrop = async (e) => {
|
||||
e.preventDefault(); setDragging(false);
|
||||
if (readOnly) return;
|
||||
const items = Array.from(e.dataTransfer.items || []);
|
||||
const fsEntries = items.map(i => i.webkitGetAsEntry?.()).filter(Boolean);
|
||||
if (fsEntries.some(en => en.isDirectory)) {
|
||||
const readFsEntry = (entry) => new Promise(resolve => {
|
||||
if (entry.isFile) { entry.file(file => { Object.defineProperty(file, 'webkitRelativePath', { value: entry.fullPath.replace(/^\//, ''), writable: false, configurable: true }); resolve([file]); }); }
|
||||
else if (entry.isDirectory) { const reader = entry.createReader(); const readAll = acc => reader.readEntries(async entries => { if (!entries.length) { resolve(acc); return; } const nested = await Promise.all(entries.map(readFsEntry)); readAll([...acc, ...nested.flat()]); }); readAll([]); }
|
||||
else { resolve([]); }
|
||||
});
|
||||
uploadFolder((await Promise.all(fsEntries.map(readFsEntry))).flat());
|
||||
} else {
|
||||
if (!e.dataTransfer.files?.length) return;
|
||||
uploadFiles(e.dataTransfer.files);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSelect = (name) => setSelected(prev => { const next = new Set(prev); next.has(name) ? next.delete(name) : next.add(name); return next; });
|
||||
|
||||
const openCtxMenu = (e, entry, childPath) => {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
setCtxMenu({ x: e.clientX, y: e.clientY, entry, childPath });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, borderRadius: 8, border: `1px solid ${dragging ? 'var(--accent)' : 'var(--border)'}`, transition: 'border-color 140ms ease', position: 'relative', overflow: 'hidden' }}
|
||||
onDragEnter={handleDragEnter} onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop}
|
||||
>
|
||||
{dragging && (
|
||||
<div style={{ position: 'absolute', inset: 0, zIndex: 10, background: 'rgba(245,165,35,0.08)', display: 'flex', alignItems: 'center', justifyContent: 'center', pointerEvents: 'none' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 28, marginBottom: 8 }}>↑</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--accent)' }}>Drop files to upload</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toolbar */}
|
||||
<div style={{ padding: '18px 21px', borderBottom: '1px solid var(--border)', flexShrink: 0, position: 'relative' }}>
|
||||
{/* Selection actions — absolute top-right like FileSharing */}
|
||||
{selected.size > 0 && (
|
||||
<div style={{ position: 'absolute', top: 18, right: 21, display: 'flex', gap: 8 }}>
|
||||
<button className="btn btn-outline" disabled={Boolean(working)} onClick={downloadSelected}>Download ({selected.size})</button>
|
||||
{!readOnly && <button className="btn btn-danger" disabled={Boolean(working)} onClick={deleteSelected}>Delete ({selected.size})</button>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ paddingRight: selected.size > 0 ? 260 : 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 2 }}>
|
||||
<div style={LABEL}>Files</div>
|
||||
{uploadProgress !== null && <span style={{ fontSize: 12, color: 'var(--text-muted)', marginLeft: 12 }}>Uploading {uploadProgress}%</span>}
|
||||
{!readOnly && (
|
||||
<>
|
||||
<input ref={folderInputRef} type="file" style={{ display: 'none' }} onChange={e => uploadFolder(e.target.files)} {...{ webkitdirectory: '' }} />
|
||||
<input ref={fileInputRef} type="file" multiple style={{ display: 'none' }} onChange={e => uploadFiles(e.target.files)} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: dragging ? 'var(--accent)' : 'var(--text-muted)', marginTop: 2, marginBottom: 14, transition: 'color 140ms ease' }}>
|
||||
Drag and drop files or folders here.
|
||||
</div>
|
||||
{/* Breadcrumbs */}
|
||||
<div style={{ fontSize: 11, fontWeight: 500, letterSpacing: 0.6, textTransform: 'uppercase', color: 'var(--text-secondary)', display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
|
||||
<button type="button" onClick={() => loadFiles(rootPath)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 11, fontWeight: 500, letterSpacing: 0.6, textTransform: 'uppercase', color: 'var(--text-primary)', fontFamily: 'inherit', padding: 0 }}>Files</button>
|
||||
{breadcrumbs.slice(pathParts(rootPath).length).map((part, i) => {
|
||||
const absIdx = pathParts(rootPath).length + i;
|
||||
return (
|
||||
<span key={`${part}-${absIdx}`} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{ opacity: 0.4 }}>/</span>
|
||||
<button type="button" onClick={() => loadFiles(pathTo(absIdx, breadcrumbs))} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 11, fontWeight: 500, letterSpacing: 0.6, textTransform: 'uppercase', color: absIdx === breadcrumbs.length - 1 ? 'var(--accent)' : 'var(--text-primary)', fontFamily: 'inherit', padding: 0 }}>{part}</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{uploadProgress !== null && (
|
||||
<div style={{ height: 2, background: 'var(--border)', flexShrink: 0 }}>
|
||||
<div style={{ height: '100%', background: 'var(--accent)', width: `${uploadProgress}%`, transition: 'width 200ms' }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File table */}
|
||||
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '18px 21px' }}>
|
||||
{error && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>{error}</div>}
|
||||
{loading ? (
|
||||
<div className="card-empty-center">Loading…</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="card-empty-center">This folder is empty.</div>
|
||||
) : (
|
||||
<table className="table-sticky-head" style={{ width: '100%', tableLayout: 'fixed', borderCollapse: 'collapse' }}>
|
||||
<colgroup>
|
||||
<col style={{ width: 32 }} />
|
||||
<col />
|
||||
<col style={{ width: 90 }} />
|
||||
<col style={{ width: 180 }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ ...TABLE_TH, textAlign: 'center', padding: '0 5px 12px' }}>
|
||||
<input type="checkbox" checked={selected.size === entries.length && entries.length > 0} onChange={e => setSelected(e.target.checked ? new Set(entries.map(en => en.name)) : new Set())} />
|
||||
</th>
|
||||
<SortTh col="name" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} style={{ ...TABLE_TH, textAlign: 'left', paddingLeft: 5 }}>Name</SortTh>
|
||||
<SortTh col="size" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} style={{ ...TABLE_TH, textAlign: 'right' }}>Size</SortTh>
|
||||
<SortTh col="modified" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} style={{ ...TABLE_TH, textAlign: 'right' }}>Modified</SortTh>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedEntries.map((entry, idx) => {
|
||||
const isDir = entry.type === 'dir';
|
||||
const childPath = entry.path;
|
||||
const isRenaming = renamingEntry?.path === entry.path;
|
||||
const rowBg = idx % 2 === 0 ? 'var(--file-row-alt-bg, rgba(255,255,255,0.02))' : 'transparent';
|
||||
return (
|
||||
<tr
|
||||
key={`${entry.type}:${entry.path}`}
|
||||
style={{ cursor: isDir ? 'pointer' : 'default' }}
|
||||
onClick={() => isDir && loadFiles(childPath)}
|
||||
onContextMenu={e => openCtxMenu(e, entry, childPath)}
|
||||
>
|
||||
<td style={{ ...TABLE_TD, background: rowBg, textAlign: 'center' }} onClick={e => { e.stopPropagation(); toggleSelect(entry.name); }}>
|
||||
<input type="checkbox" checked={selected.has(entry.name)} onChange={() => toggleSelect(entry.name)} onClick={e => e.stopPropagation()} />
|
||||
</td>
|
||||
<td style={{ ...TABLE_TD, background: rowBg }}>
|
||||
{isRenaming ? (
|
||||
<form style={{ display: 'flex', gap: 6 }} onSubmit={renameEntry} onClick={e => e.stopPropagation()}>
|
||||
<input autoFocus type="text" value={renameValue} onChange={e => setRenameValue(e.target.value)} disabled={Boolean(working)} onKeyDown={e => { if (e.key === 'Escape') setRenamingEntry(null); }} style={{ fontSize: 13, padding: '2px 8px', borderRadius: 4, border: '1px solid var(--border)', background: 'var(--card-bg-2)', color: 'var(--text-primary)', flex: 1, minWidth: 0 }} />
|
||||
<LoadingButton type="submit" className="btn btn-outline" loading={working === `ren:${entry.path}`} disabled={!renameValue.trim()} loadingText="…">Save</LoadingButton>
|
||||
<button type="button" className="btn btn-outline" onClick={() => setRenamingEntry(null)}>Cancel</button>
|
||||
</form>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<FileIcon entry={entry} />
|
||||
{isDir ? (
|
||||
<button type="button" className="dashboard-inline-link" onClick={e => { e.stopPropagation(); loadFiles(childPath); }} style={{ fontSize: 13, fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'left' }}>{entry.name}</button>
|
||||
) : (
|
||||
<span style={{ fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{entry.name}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ ...TABLE_TD, background: rowBg, textAlign: 'right', fontSize: 12 }}>{isDir ? '—' : formatBytes(entry.size)}</td>
|
||||
<td style={{ ...TABLE_TD, background: rowBg, textAlign: 'right', fontSize: 12 }}>{formatDate(entry.mtime)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Context menu — portal to escape stacking context */}
|
||||
{ctxMenu && createPortal(
|
||||
<div onClick={e => e.stopPropagation()} style={{ ...popupMenuStyle, position: 'fixed', left: ctxMenu.x, top: ctxMenu.y, zIndex: 9999, padding: '4px 0', minWidth: 160 }}>
|
||||
{ctxMenu.entry?.type === 'dir' && (
|
||||
<button style={ITEM_STYLE} onMouseEnter={e => e.currentTarget.style.background = 'var(--interactive-row-hover)'} onMouseLeave={e => e.currentTarget.style.background = 'none'} onClick={() => { loadFiles(ctxMenu.childPath); setCtxMenu(null); }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" /></svg>
|
||||
Open
|
||||
</button>
|
||||
)}
|
||||
{ctxMenu.entry?.type !== 'dir' && (
|
||||
<button style={ITEM_STYLE} onMouseEnter={e => e.currentTarget.style.background = 'var(--interactive-row-hover)'} onMouseLeave={e => e.currentTarget.style.background = 'none'} onClick={() => { downloadFile(ctxMenu.entry); setCtxMenu(null); }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7,10 12,15 17,10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
Download
|
||||
</button>
|
||||
)}
|
||||
{ctxMenu.entry && !readOnly && (
|
||||
<>
|
||||
<div style={SEP} />
|
||||
<button style={ITEM_STYLE} onMouseEnter={e => e.currentTarget.style.background = 'var(--interactive-row-hover)'} onMouseLeave={e => e.currentTarget.style.background = 'none'} onClick={() => { setRenamingEntry(ctxMenu.entry); setRenameValue(ctxMenu.entry.name); setCtxMenu(null); }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||
Rename
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{ctxMenu.entry && (
|
||||
<>
|
||||
<div style={SEP} />
|
||||
<button style={ITEM_STYLE} onMouseEnter={e => e.currentTarget.style.background = 'var(--interactive-row-hover)'} onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
||||
onClick={() => {
|
||||
const items = selected.has(ctxMenu.entry.name)
|
||||
? [...selected].map(n => { const en = entries.find(e => e.name === n); return { name: n, path: en?.path ?? joinVirtualPath(currentPath, n) }; })
|
||||
: [{ name: ctxMenu.entry.name, path: ctxMenu.childPath }];
|
||||
setClipboard({ items, mode: 'copy' }); setCtxMenu(null);
|
||||
}}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
|
||||
Copy
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<button style={ITEM_STYLE} onMouseEnter={e => e.currentTarget.style.background = 'var(--interactive-row-hover)'} onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
||||
onClick={() => {
|
||||
const items = selected.has(ctxMenu.entry.name)
|
||||
? [...selected].map(n => { const en = entries.find(e => e.name === n); return { name: n, path: en?.path ?? joinVirtualPath(currentPath, n) }; })
|
||||
: [{ name: ctxMenu.entry.name, path: ctxMenu.childPath }];
|
||||
setClipboard({ items, mode: 'move' }); setCtxMenu(null);
|
||||
}}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="14.47" y1="14.48" x2="20" y2="20"/><line x1="8.12" y1="8.12" x2="12" y2="12"/></svg>
|
||||
Cut
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{clipboard && (
|
||||
<>
|
||||
<div style={SEP} />
|
||||
<button style={ITEM_STYLE} onMouseEnter={e => e.currentTarget.style.background = 'var(--interactive-row-hover)'} onMouseLeave={e => e.currentTarget.style.background = 'none'} onClick={() => { pasteClipboard(); setCtxMenu(null); }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M16 4h2a2 2 0 012 2v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6a2 2 0 012-2h2"/><rect x="8" y="2" width="8" height="4" rx="1"/></svg>
|
||||
Paste here{clipboard.items.length > 1 ? ` (${clipboard.items.length})` : ''}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{!readOnly && ctxMenu.entry && (
|
||||
<>
|
||||
<div style={SEP} />
|
||||
<button style={{ ...ITEM_STYLE, color: 'var(--danger)' }} onMouseEnter={e => e.currentTarget.style.background = 'rgba(255,80,80,0.08)'} onMouseLeave={e => e.currentTarget.style.background = 'none'} onClick={() => { setCtxMenu(null); deleteEntry(ctxMenu.entry); }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="3,6 5,6 21,6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,17 +15,26 @@ export default function FilterDropdown({ value, onChange, options }) {
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open]);
|
||||
const current = options.find(o => o.value === value);
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative' }}>
|
||||
<button className="btn btn-outline btn-sm" onClick={() => setOpen(o => !o)} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<div ref={ref} style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
|
||||
<button
|
||||
className="btn btn-outline"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
|
||||
aria-label="Filter"
|
||||
title="Filter"
|
||||
>
|
||||
<FilterIcon />
|
||||
{current?.label}
|
||||
</button>
|
||||
{open && (
|
||||
<div style={{ position: 'absolute', top: 'calc(100% + 4px)', right: 0, background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, zIndex: 200, minWidth: 160, boxShadow: '0 4px 12px rgba(0,0,0,0.25)' }}>
|
||||
<div className="site-header-avatar-menu" style={{ top: 'calc(100% + 4px)', minWidth: 160 }}>
|
||||
{options.map(opt => (
|
||||
<button key={opt.value} onClick={() => { onChange(opt.value); setOpen(false); }} style={{ display: 'block', width: '100%', padding: '7px 14px', textAlign: 'left', background: value === opt.value ? 'rgba(245,165,35,0.08)' : 'transparent', fontSize: 13, color: value === opt.value ? 'var(--accent)' : 'var(--text-primary)', border: 'none', cursor: 'pointer' }}>
|
||||
<button
|
||||
key={opt.value}
|
||||
className="site-header-avatar-item"
|
||||
onClick={() => { onChange(opt.value); setOpen(false); }}
|
||||
style={value === opt.value ? { color: 'var(--accent)' } : undefined}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
+55
-25
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import PageLoader from './PageLoader';
|
||||
import ProfileAvatar from './ProfileAvatar';
|
||||
import { NavLink, Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
@@ -7,12 +8,12 @@ const ICONS = {
|
||||
dashboard: <svg viewBox="0 0 16 16" fill="currentColor"><rect x="1" y="1" width="6" height="6" rx="1.5"/><rect x="9" y="1" width="6" height="6" rx="1.5"/><rect x="1" y="9" width="6" height="6" rx="1.5"/><rect x="9" y="9" width="6" height="6" rx="1.5"/></svg>,
|
||||
requests: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><rect x="2" y="2" width="12" height="12" rx="1.5"/><line x1="5" y1="5.5" x2="11" y2="5.5"/><line x1="5" y1="8" x2="11" y2="8"/><line x1="5" y1="10.5" x2="8" y2="10.5"/></svg>,
|
||||
projects: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M1.5 5.5C1.5 4.67 2.17 4 3 4h2.5l1.5 2H13c.83 0 1.5.67 1.5 1.5v5.5c0 .83-.67 1.5-1.5 1.5H3c-.83 0-1.5-.67-1.5-1.5V5.5z"/></svg>,
|
||||
fileSharing: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 2H4a1.5 1.5 0 00-1.5 1.5v9A1.5 1.5 0 004 14h8a1.5 1.5 0 001.5-1.5V6L9 2z"/><polyline points="9,2 9,6 13.5,6"/></svg>,
|
||||
invoices: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><rect x="2" y="1.5" width="12" height="13" rx="1"/><line x1="8" y1="4" x2="8" y2="12"/><path d="M10 5.5H7a1.5 1.5 0 000 3h2a1.5 1.5 0 010 3H5.5"/></svg>,
|
||||
notes: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><rect x="3" y="1.5" width="10" height="13" rx="1"/><line x1="5.5" y1="5" x2="10.5" y2="5"/><line x1="5.5" y1="7.5" x2="10.5" y2="7.5"/><line x1="5.5" y1="10" x2="8.5" y2="10"/></svg>,
|
||||
survey: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="1.5" y="9.5" width="3" height="5" rx="0.5"/><rect x="6.5" y="6" width="3" height="8.5" rx="0.5"/><rect x="11.5" y="2.5" width="3" height="12" rx="0.5"/></svg>,
|
||||
brandBook: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M2.5 2.5h4a2 2 0 012 2v9a2 2 0 00-2-2h-4V2.5z"/><path d="M13.5 2.5h-4a2 2 0 00-2 2v9a2 2 0 012-2h4V2.5z"/></svg>,
|
||||
converter: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="1.5" y="1.5" width="13" height="13" rx="1.5"/><circle cx="5.5" cy="5.5" r="1.5"/><path d="M1.5 11.5l3.5-3.5 3 3L11 7.5l3 3"/></svg>,
|
||||
reports: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M2.5 13.5h11"/><path d="M4.5 11V7.5"/><path d="M8 11V4.5"/><path d="M11.5 11V6"/></svg>,
|
||||
passwords: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="7" width="10" height="7.5" rx="1"/><path d="M5 7V5.5a3 3 0 016 0V7"/><circle cx="8" cy="10.5" r="1" fill="currentColor" stroke="none"/></svg>,
|
||||
companies: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="1.5" y="5" width="13" height="9.5" rx="1"/><path d="M5.5 5V3a1 1 0 011-1h3a1 1 0 011 1v2"/><line x1="8" y1="5" x2="8" y2="14.5"/><line x1="1.5" y1="9" x2="14.5" y2="9"/></svg>,
|
||||
users: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><circle cx="6" cy="5" r="2.5"/><path d="M1 14c0-2.76 2.24-5 5-5s5 2.24 5 5"/><circle cx="12.5" cy="5.5" r="2"/><path d="M12.5 10c1.93 0 3.5 1.57 3.5 3.5"/></svg>,
|
||||
@@ -28,14 +29,14 @@ function TeamNav({ onNav }) {
|
||||
const primaryLinks = [
|
||||
{ to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
|
||||
{ to: '/tasks', label: 'Tasks', icon: ICONS.requests },
|
||||
{ to: '/invoices', label: 'Finances', icon: ICONS.invoices },
|
||||
{ to: '/file-sharing', label: 'File Sharing', icon: ICONS.fileSharing },
|
||||
{ to: '/finances', label: 'Finances', icon: ICONS.invoices },
|
||||
];
|
||||
|
||||
const utilityLinks = [
|
||||
{ to: '/survey-maker', label: 'Survey Maker', icon: ICONS.survey },
|
||||
{ to: '/brand-book', label: 'Brand Book Maker', icon: ICONS.brandBook },
|
||||
{ to: '/converters', label: 'Image Converter', icon: ICONS.converter },
|
||||
{ to: '/reports', label: 'Reports', icon: ICONS.reports },
|
||||
{ to: '/fourge-passwords', label: 'Fourge Passwords', icon: ICONS.passwords },
|
||||
];
|
||||
|
||||
@@ -73,8 +74,7 @@ function ClientNav({ onNav }) {
|
||||
const links = [
|
||||
{ to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
|
||||
{ to: '/tasks', label: 'Tasks', icon: ICONS.requests },
|
||||
{ to: '/file-sharing', label: 'File Sharing', icon: ICONS.fileSharing },
|
||||
{ to: '/my-invoices', label: 'Invoices', icon: ICONS.invoices },
|
||||
{ to: '/client-invoices', label: 'Invoices', icon: ICONS.invoices },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -94,8 +94,7 @@ function ExternalNav({ onNav }) {
|
||||
const links = [
|
||||
{ to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
|
||||
{ to: '/tasks', label: 'Tasks', icon: ICONS.requests },
|
||||
{ to: '/my-invoices-sub', label: 'Invoices', icon: ICONS.invoices },
|
||||
{ to: '/file-sharing', label: 'File Sharing', icon: ICONS.fileSharing },
|
||||
{ to: '/subs-invoices', label: 'Invoices', icon: ICONS.invoices },
|
||||
{ to: '/survey-maker', label: 'Survey Maker', icon: ICONS.survey },
|
||||
{ to: '/brand-book', label: 'Brand Book Maker', icon: ICONS.brandBook },
|
||||
{ to: '/converters', label: 'Image Converter', icon: ICONS.converter },
|
||||
@@ -127,7 +126,7 @@ function getInitialTheme() {
|
||||
return 'dark';
|
||||
}
|
||||
|
||||
export default function Layout({ children }) {
|
||||
export default function Layout({ children, loading = false }) {
|
||||
const { currentUser, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
@@ -144,20 +143,54 @@ export default function Layout({ children }) {
|
||||
const timeOfDay = hour < 12 ? 'morning' : hour < 17 ? 'afternoon' : 'evening';
|
||||
const firstName = currentUser?.name?.split(' ')[0] || '';
|
||||
const isProfileRoute = location.pathname === '/profile' || location.pathname.startsWith('/profile/');
|
||||
const isFileSharingRoute = location.pathname === '/file-sharing';
|
||||
const isTaskDetailRoute = location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/requests/');
|
||||
const isInvoiceDetailRoute =
|
||||
location.pathname.startsWith('/finances/') ||
|
||||
location.pathname.startsWith('/invoices/') ||
|
||||
location.pathname.startsWith('/client-invoices/') ||
|
||||
location.pathname.startsWith('/subs-invoices/') ||
|
||||
location.pathname.startsWith('/my-invoices-sub/') ||
|
||||
location.pathname.startsWith('/sub-invoices/');
|
||||
const isRequestsRoute = location.pathname === '/tasks' || location.pathname === '/requests' || location.pathname === '/team/tasks' || isTaskDetailRoute;
|
||||
const isFinancesRoute = location.pathname === '/invoices' || location.pathname.startsWith('/invoices/') || location.pathname === '/sub-invoices' || location.pathname.startsWith('/sub-invoices/');
|
||||
const headerTitle = isProfileRoute ? 'Profile' : isFileSharingRoute ? 'File Sharing' : isFinancesRoute ? 'Finances' : isRequestsRoute && !isTaskDetailRoute ? 'Tasks & Projects' : !isTaskDetailRoute ? `Good ${timeOfDay}${firstName ? `, ${firstName}` : ''}` : null;
|
||||
const isFinancesRoute =
|
||||
location.pathname === '/finances' ||
|
||||
location.pathname.startsWith('/finances/') ||
|
||||
location.pathname === '/invoices' ||
|
||||
location.pathname.startsWith('/invoices/') ||
|
||||
location.pathname === '/sub-invoices' ||
|
||||
location.pathname.startsWith('/sub-invoices/') ||
|
||||
location.pathname === '/client-invoices' ||
|
||||
location.pathname.startsWith('/client-invoices/') ||
|
||||
location.pathname === '/my-invoices' ||
|
||||
location.pathname.startsWith('/my-invoices/') ||
|
||||
location.pathname === '/subs-invoices' ||
|
||||
location.pathname.startsWith('/subs-invoices/') ||
|
||||
location.pathname === '/my-invoices-sub' ||
|
||||
location.pathname.startsWith('/my-invoices-sub/');
|
||||
const isReportsRoute = location.pathname === '/reports';
|
||||
const financeHeaderTitle = currentUser?.role === 'team' ? 'Finances' : 'Invoices';
|
||||
const headerTitle = isProfileRoute ? 'Profile' : isFinancesRoute ? financeHeaderTitle : isReportsRoute ? 'Reports' : isRequestsRoute && !isTaskDetailRoute ? 'Tasks & Projects' : !isTaskDetailRoute && !isInvoiceDetailRoute ? `Good ${timeOfDay}${firstName ? `, ${firstName}` : ''}` : null;
|
||||
const headerSubtitle = isProfileRoute
|
||||
? 'Account details and security settings.'
|
||||
: isFileSharingRoute
|
||||
? 'Browse, share and manage files.'
|
||||
: isFinancesRoute
|
||||
? 'Invoices, expenses and subcontractor POs.'
|
||||
: isRequestsRoute && !isTaskDetailRoute
|
||||
: isFinancesRoute
|
||||
? currentUser?.role === 'external'
|
||||
? 'Track completed work, invoice history and payment status.'
|
||||
: currentUser?.role === 'client'
|
||||
? 'View invoices, payment status and receipt history.'
|
||||
: 'Invoices, expenses and subcontractor POs.'
|
||||
: isReportsRoute
|
||||
? 'Track task versions, billing state and exportable summaries.'
|
||||
: isRequestsRoute && !isTaskDetailRoute
|
||||
? 'Browse and manage all tasks and projects.'
|
||||
: isTaskDetailRoute ? null : "Here's what's happening today.";
|
||||
: isTaskDetailRoute || isInvoiceDetailRoute ? null : "Here's what's happening today.";
|
||||
const detailBackTarget = isTaskDetailRoute
|
||||
? '/tasks'
|
||||
: currentUser?.role === 'team'
|
||||
? '/finances'
|
||||
: currentUser?.role === 'client'
|
||||
? '/client-invoices'
|
||||
: '/subs-invoices';
|
||||
const detailBackLabel = isTaskDetailRoute ? 'Tasks & Projects' : financeHeaderTitle;
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
@@ -217,7 +250,7 @@ export default function Layout({ children }) {
|
||||
|
||||
return (
|
||||
<div className="app-layout">
|
||||
{navigating && <PageLoader />}
|
||||
{(navigating || loading) && <PageLoader />}
|
||||
{menuOpen && <div className="sidebar-overlay" onClick={() => setMenuOpen(false)} />}
|
||||
|
||||
<aside className={`sidebar${menuOpen ? ' sidebar-open' : ''}`}>
|
||||
@@ -245,11 +278,11 @@ export default function Layout({ children }) {
|
||||
<main className="main-content">
|
||||
<div className="site-header">
|
||||
<div>
|
||||
{isTaskDetailRoute ? (
|
||||
{isTaskDetailRoute || isInvoiceDetailRoute ? (
|
||||
<div>
|
||||
<Link to="/tasks" className="dashboard-inline-link" style={{ display: 'inline-flex', alignItems: 'center', gap: 1, color: 'var(--text-muted)', textDecoration: 'none' }}>
|
||||
<Link to={detailBackTarget} className="dashboard-inline-link" style={{ display: 'inline-flex', alignItems: 'center', gap: 1, color: 'var(--text-muted)', textDecoration: 'none' }}>
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ width: 24, height: 24, flexShrink: 0 }}><polyline points="10 4 6 8 10 12"/></svg>
|
||||
<span style={{ fontSize: 24, fontWeight: 500, lineHeight: 1.2, letterSpacing: '-0.3px', position: 'relative', top: 2 }}>Tasks & Projects</span>
|
||||
<span style={{ fontSize: 24, fontWeight: 500, lineHeight: 1.2, letterSpacing: '-0.3px', position: 'relative', top: 2 }}>{detailBackLabel}</span>
|
||||
</Link>
|
||||
<div className="site-header-sub" style={{ paddingLeft: 25 }}>Return back to previous page</div>
|
||||
</div>
|
||||
@@ -336,10 +369,7 @@ export default function Layout({ children }) {
|
||||
|
||||
<div className="site-header-avatar-wrap" ref={avatarRef}>
|
||||
<button className="site-header-avatar-btn" onClick={() => setAvatarOpen(o => !o)} aria-label="Account menu" style={{ overflow: 'hidden' }}>
|
||||
{currentUser?.avatar_url
|
||||
? <img src={currentUser.avatar_url} alt={currentUser.name} style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '50%' }} />
|
||||
: <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4.4 3.6-7 8-7s8 2.6 8 7" fill="currentColor"/></svg>
|
||||
}
|
||||
<ProfileAvatar profile={currentUser} name={currentUser?.name} size={68} fontSize={20} />
|
||||
</button>
|
||||
{avatarOpen && (
|
||||
<div className="site-header-avatar-menu">
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function PageLoader({ label = 'Loading', progress = null, scope =
|
||||
<>
|
||||
<div style={{ height: 4, borderRadius: 2, background: 'var(--card-bg-2)', overflow: 'hidden', marginBottom: hasProgress ? 8 : 0 }}>
|
||||
<div
|
||||
className={hasProgress ? undefined : 'file-browser-progress-bar indeterminate'}
|
||||
className={hasProgress ? undefined : 'shared-progress-bar indeterminate'}
|
||||
style={{ height: '100%', borderRadius: 2, background: 'var(--accent)', width: hasProgress ? `${progress}%` : undefined, transition: 'width 0.2s ease' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
function normalizeName(value) {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function resolveProfileAvatar({
|
||||
avatarUrl = '',
|
||||
profile = null,
|
||||
profileId = '',
|
||||
name = '',
|
||||
profilesById = null,
|
||||
profilesByName = null,
|
||||
}) {
|
||||
if (avatarUrl) return avatarUrl;
|
||||
if (profile?.avatar_url) return profile.avatar_url;
|
||||
if (profileId && profilesById?.get(profileId)?.avatar_url) return profilesById.get(profileId).avatar_url;
|
||||
const normalized = normalizeName(name || profile?.name);
|
||||
if (normalized && profilesByName?.get(normalized)?.avatar_url) return profilesByName.get(normalized).avatar_url;
|
||||
return '';
|
||||
}
|
||||
|
||||
export default function ProfileAvatar({
|
||||
profile = null,
|
||||
profileId = '',
|
||||
name = '',
|
||||
avatarUrl = '',
|
||||
profilesById = null,
|
||||
profilesByName = null,
|
||||
size = 32,
|
||||
fontSize = 12,
|
||||
fallbackBg = 'var(--accent)',
|
||||
fallbackColor = '#000',
|
||||
alt = '',
|
||||
style = {},
|
||||
}) {
|
||||
const resolvedUrl = resolveProfileAvatar({ avatarUrl, profile, profileId, name, profilesById, profilesByName });
|
||||
const initials = String(name || profile?.name || '?')
|
||||
.split(' ')
|
||||
.map((part) => part[0])
|
||||
.slice(0, 2)
|
||||
.join('')
|
||||
.toUpperCase();
|
||||
|
||||
if (resolvedUrl) {
|
||||
return (
|
||||
<div style={{ width: size, height: size, borderRadius: '50%', overflow: 'hidden', flexShrink: 0, ...style }}>
|
||||
<img src={resolvedUrl} alt={alt || name || profile?.name || ''} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: '50%',
|
||||
background: fallbackBg,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize, fontWeight: 600, color: fallbackColor, lineHeight: 1 }}>{initials || '?'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ const defaultDeadline = () => addDaysToDateOnly(getTodayDateOnlyEST(), 3);
|
||||
const modalLabelStyle = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 6 };
|
||||
const modalInputStyle = { fontSize: 13, padding: '0 5px', textAlign: 'left', lineHeight: 1 };
|
||||
const modalTextAreaStyle = { ...modalInputStyle, minHeight: 100, padding: '8px 5px', lineHeight: 1.4 };
|
||||
const modalActionButtonStyle = { width: 132, justifyContent: 'center', flex: '0 0 132px' };
|
||||
const emptyForm = (companyId = '') => ({
|
||||
companyId,
|
||||
project: '',
|
||||
@@ -43,7 +44,11 @@ export default function RequestForm({
|
||||
initialValues = null,
|
||||
lockedFields = [],
|
||||
}) {
|
||||
const [form, setForm] = useState(() => ({ ...emptyForm(initialCompanyId), ...(initialValues || {}) }));
|
||||
const [form, setForm] = useState(() => ({
|
||||
...emptyForm(initialCompanyId),
|
||||
requestedBy: showRequester ? (currentUser?.id || '') : '',
|
||||
...(initialValues || {}),
|
||||
}));
|
||||
const prevCompanyIdRef = useRef(initialValues?.companyId || initialCompanyId || null);
|
||||
const [files, setFiles] = useState([]);
|
||||
const [existingProjects, setExistingProjects] = useState([]);
|
||||
@@ -54,16 +59,17 @@ export default function RequestForm({
|
||||
const [signFamilies, setSignFamilies] = useState([]);
|
||||
const [signCount, setSignCount] = useState('');
|
||||
const [signs, setSigns] = useState([]);
|
||||
const [localError, setLocalError] = useState('');
|
||||
|
||||
const isBrandBook = form.serviceType === 'Brand Book';
|
||||
const usesSignFields = form.serviceType === 'Brand Book';
|
||||
|
||||
useEffect(() => {
|
||||
supabase.from('sign_families').select('name').order('sort_order').then(({ data }) => setSignFamilies((data || []).map(r => r.name)));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isBrandBook) { setSignCount(''); setSigns([]); }
|
||||
}, [isBrandBook]);
|
||||
if (!usesSignFields) { setSignCount(''); setSigns([]); }
|
||||
}, [usesSignFields]);
|
||||
|
||||
useEffect(() => {
|
||||
const n = Math.max(0, parseInt(signCount) || 0);
|
||||
@@ -113,9 +119,15 @@ export default function RequestForm({
|
||||
prevCompanyIdRef.current = companyId;
|
||||
}, [companyId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const set = (field) => (e) => setForm(f => ({ ...f, [field]: e.target.value }));
|
||||
const set = (field) => (e) => {
|
||||
if (localError) setLocalError('');
|
||||
setForm(f => ({ ...f, [field]: e.target.value }));
|
||||
};
|
||||
|
||||
const setSign = (id, field, value) => setSigns(prev => prev.map(s => s.id === id ? { ...s, [field]: value } : s));
|
||||
const setSign = (id, field, value) => {
|
||||
if (localError) setLocalError('');
|
||||
setSigns(prev => prev.map(s => s.id === id ? { ...s, [field]: value } : s));
|
||||
};
|
||||
|
||||
const allProjectNames = [
|
||||
...existingProjects.map(p => p.name),
|
||||
@@ -143,18 +155,56 @@ export default function RequestForm({
|
||||
};
|
||||
|
||||
const showCompanySelect = companies.length > 1 || showRequester;
|
||||
const requesterOptions = [
|
||||
...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)` }] : []),
|
||||
...companyUsers.filter(u => u.id !== currentUser?.id),
|
||||
];
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
const requestedByName = currentUser?.name || '';
|
||||
setLocalError('');
|
||||
const selectedRequester = showRequester
|
||||
? requesterOptions.find(option => option.id === form.requestedBy) || null
|
||||
: null;
|
||||
const requestedBy = showRequester
|
||||
? (selectedRequester?.id || '')
|
||||
: (currentUser?.id || '');
|
||||
const requestedByName = showRequester
|
||||
? ((selectedRequester?.name || '').replace(/\s+\(You\)$/, '') || '')
|
||||
: (currentUser?.name || '');
|
||||
let normalizedSignCount = null;
|
||||
let normalizedSigns = [];
|
||||
|
||||
if (usesSignFields) {
|
||||
normalizedSignCount = parseInt(signCount, 10) || null;
|
||||
const formData = new FormData(e.currentTarget);
|
||||
normalizedSigns = Array.from({ length: normalizedSignCount || 0 }, (_, index) => {
|
||||
const stateSign = signs[index] || {};
|
||||
const domValue = formData.get(`sign_info_${index + 1}`);
|
||||
const signName = typeof domValue === 'string'
|
||||
? domValue.trim()
|
||||
: String(stateSign.signName || '').trim();
|
||||
return {
|
||||
id: stateSign.id || `sign-${index + 1}`,
|
||||
signName,
|
||||
signFamily: stateSign.signFamily || '',
|
||||
};
|
||||
}).filter(sign => sign.signName || sign.signFamily);
|
||||
|
||||
if ((normalizedSignCount || 0) > 0 && normalizedSigns.length !== normalizedSignCount) {
|
||||
setLocalError('Please fill in the sign information for each sign before submitting.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
onSubmit(
|
||||
{
|
||||
...form,
|
||||
companyId,
|
||||
requestedBy: currentUser?.id || '',
|
||||
requestedBy,
|
||||
requestedByName,
|
||||
signCount: isBrandBook ? (parseInt(signCount) || null) : null,
|
||||
signs: isBrandBook ? signs : [],
|
||||
signCount: usesSignFields ? normalizedSignCount : null,
|
||||
signs: usesSignFields ? normalizedSigns : [],
|
||||
},
|
||||
files,
|
||||
existingProjects
|
||||
@@ -214,6 +264,24 @@ export default function RequestForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showRequester && (
|
||||
<div className="form-group">
|
||||
<label style={modalLabelStyle}>Requested By *</label>
|
||||
<select
|
||||
value={form.requestedBy}
|
||||
onChange={set('requestedBy')}
|
||||
required
|
||||
disabled={!companyId}
|
||||
style={modalInputStyle}
|
||||
>
|
||||
<option value="">{companyId ? 'Select requester...' : 'Select company first'}</option>
|
||||
{requesterOptions.map(user => (
|
||||
<option key={user.id} value={user.id}>{user.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Row 3: Title/Location + Mark as Hot inline */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 12, alignItems: 'end', marginBottom: 16 }}>
|
||||
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||
@@ -226,7 +294,7 @@ export default function RequestForm({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{isBrandBook && (
|
||||
{usesSignFields && (
|
||||
<div className="form-group">
|
||||
<label style={modalLabelStyle}>Sign Count *</label>
|
||||
<input
|
||||
@@ -242,7 +310,7 @@ export default function RequestForm({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isBrandBook && signs.length > 0 && (
|
||||
{usesSignFields && signs.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
|
||||
{signs.map((sign, i) => (
|
||||
<div key={sign.id} style={{ border: '1px solid var(--border)', borderRadius: 6, padding: '10px 12px' }}>
|
||||
@@ -251,6 +319,7 @@ export default function RequestForm({
|
||||
<label style={modalLabelStyle}>Sign Information *</label>
|
||||
<input
|
||||
type="text"
|
||||
name={`sign_info_${i + 1}`}
|
||||
placeholder="e.g. Main Entry, Drive Thru"
|
||||
value={sign.signName}
|
||||
onChange={e => setSign(sign.id, 'signName', e.target.value)}
|
||||
@@ -264,19 +333,19 @@ export default function RequestForm({
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label style={modalLabelStyle}>{isBrandBook ? 'Notes' : 'Description'} *</label>
|
||||
<label style={modalLabelStyle}>{usesSignFields ? 'Notes' : 'Description'} *</label>
|
||||
<textarea placeholder="Notes on the request..." value={form.description} onChange={set('description')} style={modalTextAreaStyle} required />
|
||||
</div>
|
||||
|
||||
<FileAttachment files={files} onChange={setFiles} />
|
||||
|
||||
{error && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>⚠ {error}</div>}
|
||||
{(localError || error) && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>⚠ {localError || error}</div>}
|
||||
|
||||
<div className="action-buttons" style={{ justifyContent: 'flex-end' }}>
|
||||
<button type="submit" className="btn btn-outline" disabled={saving}>
|
||||
<div className="action-buttons modal-action-row" style={{ justifyContent: 'flex-end' }}>
|
||||
<button type="submit" className="btn btn-outline" disabled={saving} style={modalActionButtonStyle}>
|
||||
{saving ? 'Submitting...' : submitLabel}
|
||||
</button>
|
||||
{onCancel && <button type="button" className="btn btn-outline" onClick={onCancel}>Cancel</button>}
|
||||
{onCancel && <button type="button" className="btn btn-outline" onClick={onCancel} style={modalActionButtonStyle}>Cancel</button>}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import SortTh from './SortTh';
|
||||
|
||||
function fmt(val) {
|
||||
return `$${Number(val || 0).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function inferItemType(item) {
|
||||
const match = String(item?.description || '').match(/\bR(\d{2})\b/i);
|
||||
if (match) {
|
||||
return Number(match[1]) <= 0
|
||||
? { label: 'New', badgeClass: 'badge-initial' }
|
||||
: { label: 'Revision', badgeClass: 'badge-client_revision' };
|
||||
}
|
||||
if (item?.task_id) return { label: 'Task', badgeClass: 'badge-in_progress' };
|
||||
return { label: 'Other', badgeClass: 'badge-initial' };
|
||||
}
|
||||
|
||||
export default function SubcontractorInvoiceDetailView({
|
||||
error,
|
||||
headerActions,
|
||||
leftCardTitle,
|
||||
leftCardBody,
|
||||
rightCardTitle,
|
||||
rightCardBody,
|
||||
sortKey,
|
||||
sortDir,
|
||||
onSort,
|
||||
items,
|
||||
notes,
|
||||
total,
|
||||
}) {
|
||||
return (
|
||||
<div className="invoice-detail-shell">
|
||||
{error ? <div className="notification notification-info" style={{ marginBottom: 16, flexShrink: 0 }}>{error}</div> : null}
|
||||
|
||||
<div className="invoice-detail-stack">
|
||||
<div className="invoice-detail-summary-grid">
|
||||
<div className="card invoice-detail-card">
|
||||
<div className="invoice-detail-section-title">{leftCardTitle}</div>
|
||||
{leftCardBody}
|
||||
</div>
|
||||
<div className="card invoice-detail-card">
|
||||
<div className="invoice-detail-card-header">
|
||||
<div className="invoice-detail-section-title" style={{ marginBottom: 0 }}>{rightCardTitle}</div>
|
||||
{headerActions ? <div className="invoice-detail-card-actions">{headerActions}</div> : null}
|
||||
</div>
|
||||
{rightCardBody}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card invoice-detail-card invoice-detail-line-items-card">
|
||||
<div className="invoice-detail-section-title">Line Items</div>
|
||||
{items.length === 0 ? (
|
||||
<div className="card-empty-center" style={{ minHeight: 120 }}>No line items.</div>
|
||||
) : (
|
||||
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ overflowY: 'auto' }}>
|
||||
<table className="table-sticky-head invoice-detail-table" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||
<colgroup>
|
||||
<col style={{ width: '12%' }} />
|
||||
<col style={{ width: '46%' }} />
|
||||
<col style={{ width: '14%' }} />
|
||||
<col style={{ width: '14%' }} />
|
||||
<col style={{ width: '14%' }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="type" sortKey={sortKey} sortDir={sortDir} onSort={onSort}>Type</SortTh>
|
||||
<SortTh col="description" sortKey={sortKey} sortDir={sortDir} onSort={onSort}>Description</SortTh>
|
||||
<SortTh col="quantity" sortKey={sortKey} sortDir={sortDir} onSort={onSort} style={{ textAlign: 'center' }}>Qty</SortTh>
|
||||
<SortTh col="unit_price" sortKey={sortKey} sortDir={sortDir} onSort={onSort} style={{ textAlign: 'right' }}>Unit Price</SortTh>
|
||||
<SortTh col="line_total" sortKey={sortKey} sortDir={sortDir} onSort={onSort} style={{ textAlign: 'right' }}>Total</SortTh>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => {
|
||||
const itemType = inferItemType(item);
|
||||
return (
|
||||
<tr key={item.id}>
|
||||
<td style={{ padding: '5px 0' }}>
|
||||
<span className={`badge ${itemType.badgeClass}`}>{itemType.label}</span>
|
||||
</td>
|
||||
<td className="invoice-detail-cell">{item.description}</td>
|
||||
<td className="invoice-detail-cell" style={{ textAlign: 'center' }}>{item.quantity}</td>
|
||||
<td className="invoice-detail-cell" style={{ textAlign: 'right' }}>{fmt(item.unit_price)}</td>
|
||||
<td className="invoice-detail-cell" style={{ textAlign: 'right', fontWeight: 400 }}>
|
||||
{fmt(Number(item.unit_price || 0) * Number(item.quantity || 1))}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<div className="invoice-detail-total-row">
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div className="invoice-detail-total-label">Total</div>
|
||||
<div className="invoice-detail-total-value">{fmt(total)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notes ? (
|
||||
<div className="card invoice-detail-card">
|
||||
<div className="invoice-detail-section-title">Notes</div>
|
||||
<p className="invoice-detail-notes">{notes}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import LoadingButton from './LoadingButton';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { sendEmail } from '../lib/email';
|
||||
import { isCompletedVersionEligible } from '../lib/invoiceVersionRules';
|
||||
|
||||
const INVOICE_TODAY = new Date().toISOString().split('T')[0];
|
||||
const REVISION_RATE = 30;
|
||||
const FIELD_LABEL_STYLE = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, display: 'block', marginBottom: 4 };
|
||||
const FIELD_INPUT_STYLE = { minHeight: 42, margin: 0 };
|
||||
const FINANCE_MODAL_INPUT_STYLE = {
|
||||
...FIELD_INPUT_STYLE,
|
||||
width: '100%',
|
||||
padding: '0 10px',
|
||||
fontSize: 13,
|
||||
color: 'var(--text-primary)',
|
||||
background: 'var(--card-bg-2)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 6,
|
||||
fontFamily: 'inherit',
|
||||
boxSizing: 'border-box',
|
||||
};
|
||||
const FINANCE_MODAL_TEXTAREA_STYLE = {
|
||||
...FINANCE_MODAL_INPUT_STYLE,
|
||||
minHeight: 72,
|
||||
padding: '10px',
|
||||
resize: 'vertical',
|
||||
};
|
||||
const FINANCE_MODAL_LINE_ITEM_INPUT_STYLE = {
|
||||
...FINANCE_MODAL_INPUT_STYLE,
|
||||
minHeight: 32,
|
||||
padding: '0 8px',
|
||||
};
|
||||
const FINANCE_MODAL_NUMBER_INPUT_STYLE = {
|
||||
...FINANCE_MODAL_LINE_ITEM_INPUT_STYLE,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
};
|
||||
const FINANCE_MODAL_TOTAL_VALUE_STYLE = {
|
||||
fontSize: 24,
|
||||
fontWeight: 500,
|
||||
lineHeight: 1.1,
|
||||
color: 'var(--accent)',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
};
|
||||
|
||||
function asArray(value) {
|
||||
if (Array.isArray(value)) return value;
|
||||
if (value == null) return [];
|
||||
return typeof value === 'object' ? [value] : [];
|
||||
}
|
||||
|
||||
function versionLabel(version = 0) {
|
||||
return `R${String(version).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function parseVersionFromDescription(description = '') {
|
||||
const match = String(description).match(/\bR(\d{2})\b/i);
|
||||
return match ? Number(match[1]) : 0;
|
||||
}
|
||||
|
||||
function subcontractorVersionRate(version, newWorkRate) {
|
||||
if (version <= 0) return newWorkRate;
|
||||
if (version === 1) return 0;
|
||||
return REVISION_RATE;
|
||||
}
|
||||
|
||||
function newItem(description = '', unitPrice = '', quantity = 1, taskId = null, isRevision = false, versionNumber = 0, lineKey = null) {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
description,
|
||||
unit_price: unitPrice,
|
||||
quantity,
|
||||
task_id: taskId,
|
||||
is_revision: isRevision,
|
||||
version_number: versionNumber,
|
||||
line_key: lineKey || (taskId ? `${taskId}:${versionNumber}` : crypto.randomUUID()),
|
||||
};
|
||||
}
|
||||
|
||||
function genNumber(count) {
|
||||
const year = new Date().getFullYear();
|
||||
return `INVSUB-${year}-${String(count + 1).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
export default function SubcontractorInvoiceForm({ embedded = false, onCancel, onSubmitted }) {
|
||||
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 [lineItemsPulse, setLineItemsPulse] = useState(false);
|
||||
const dragItem = useRef(null);
|
||||
const lineItemsRef = useRef(null);
|
||||
|
||||
const rate = Number(currentUser?.brand_book_rate || 60);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
if (!currentUser?.id) {
|
||||
setCompletedTasks([]);
|
||||
setLoadingTasks(false);
|
||||
return;
|
||||
}
|
||||
setLoadingTasks(true);
|
||||
setError('');
|
||||
try {
|
||||
const [{ data: tasks, error: tasksError }, { data: existingInvoices, error: invoicesError }, { data: nextNum, error: nextNumError }] = await Promise.all([
|
||||
supabase
|
||||
.from('tasks')
|
||||
.select('id, title, status, current_version, project:projects(name)')
|
||||
.in('status', ['not_started', 'in_progress', 'on_hold', 'client_review', 'client_approved', 'invoiced', 'paid'])
|
||||
.eq('assigned_to', currentUser.id)
|
||||
.order('title'),
|
||||
supabase
|
||||
.from('subcontractor_invoices')
|
||||
.select('id, status, items:subcontractor_invoice_items(task_id, description)')
|
||||
.in('status', ['submitted', 'paid']),
|
||||
supabase.rpc('get_next_sub_invoice_number'),
|
||||
]);
|
||||
if (tasksError) throw tasksError;
|
||||
if (invoicesError) throw invoicesError;
|
||||
if (nextNumError) throw nextNumError;
|
||||
|
||||
setInvoiceNumber(nextNum || genNumber(0));
|
||||
|
||||
const invoicedUnitKeys = new Set(
|
||||
(existingInvoices || []).flatMap((inv) => asArray(inv.items).map((item) => {
|
||||
if (!item.task_id) return null;
|
||||
return `${item.task_id}:${parseVersionFromDescription(item.description)}`;
|
||||
}).filter(Boolean))
|
||||
);
|
||||
|
||||
const taskIds = (tasks || []).map((task) => task.id).filter(Boolean);
|
||||
const { data: submissionRows, error: submissionsError } = taskIds.length > 0
|
||||
? await supabase
|
||||
.from('submissions')
|
||||
.select('task_id, deliveries(version_number, sent_by, sent_at)')
|
||||
.in('task_id', taskIds)
|
||||
: { data: [], error: null };
|
||||
if (submissionsError) throw submissionsError;
|
||||
|
||||
const deliveriesByTask = new Map();
|
||||
for (const row of (submissionRows || [])) {
|
||||
const taskId = row.task_id;
|
||||
if (!taskId) continue;
|
||||
const bucket = deliveriesByTask.get(taskId) || new Map();
|
||||
for (const delivery of asArray(row.deliveries)) {
|
||||
if ((delivery.sent_by || '').trim().toLowerCase() !== (currentUser.name || '').trim().toLowerCase()) continue;
|
||||
const version = Number(delivery.version_number || 0);
|
||||
if (!bucket.has(version)) bucket.set(version, delivery);
|
||||
}
|
||||
deliveriesByTask.set(taskId, bucket);
|
||||
}
|
||||
|
||||
const units = (tasks || []).flatMap((task) => {
|
||||
const versions = [...(deliveriesByTask.get(task.id)?.keys() || [])].sort((a, b) => a - b);
|
||||
return versions
|
||||
.filter((version) => isCompletedVersionEligible(task, version))
|
||||
.map((version) => {
|
||||
const key = `${task.id}:${version}`;
|
||||
return {
|
||||
key,
|
||||
task_id: task.id,
|
||||
version_number: version,
|
||||
is_revision: version > 0,
|
||||
title: task.title,
|
||||
project_name: task.project?.name || '',
|
||||
description: `${task.project?.name ? `${task.project.name} • ` : ''}${task.title} – ${versionLabel(version)}`,
|
||||
rate: subcontractorVersionRate(version, rate),
|
||||
};
|
||||
});
|
||||
}).filter((unit) => !invoicedUnitKeys.has(unit.key));
|
||||
|
||||
setCompletedTasks(units);
|
||||
} catch (err) {
|
||||
console.error('Failed to load subcontractor invoice tasks:', err);
|
||||
setCompletedTasks([]);
|
||||
setError(err?.message || 'Could not load completed tasks.');
|
||||
} finally {
|
||||
setLoadingTasks(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, [currentUser?.id, currentUser?.name, rate]);
|
||||
|
||||
const addedTaskIds = useMemo(() => new Set(items.map((item) => item.line_key).filter(Boolean)), [items]);
|
||||
|
||||
const focusLineItems = () => {
|
||||
setLineItemsPulse(true);
|
||||
lineItemsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
window.setTimeout(() => setLineItemsPulse(false), 1200);
|
||||
};
|
||||
|
||||
const addTask = (task) => {
|
||||
if (addedTaskIds.has(task.key)) return;
|
||||
const toAdd = [newItem(task.description, task.rate, 1, task.task_id, task.is_revision, task.version_number, task.key)];
|
||||
setItems((prev) => {
|
||||
if (prev.length === 1 && !prev[0].description && !prev[0].unit_price) return toAdd;
|
||||
return [...prev, ...toAdd];
|
||||
});
|
||||
focusLineItems();
|
||||
};
|
||||
|
||||
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 handleCancel = () => {
|
||||
if (saving) return;
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
navigate('/subs-invoices');
|
||||
};
|
||||
|
||||
const total = items.reduce((sum, item) => sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0), 0);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const valid = items.filter((item) => String(item.description).trim());
|
||||
if (!valid.length) {
|
||||
setError('Add at least one line item.');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const { data: inv, error: invErr } = await supabase
|
||||
.from('subcontractor_invoices')
|
||||
.insert({
|
||||
profile_id: currentUser.id,
|
||||
invoice_number: invoiceNumber.trim(),
|
||||
status: 'submitted',
|
||||
notes: notes.trim(),
|
||||
submitted_at: new Date().toISOString(),
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (invErr) throw invErr;
|
||||
|
||||
const { error: itemsErr } = await supabase.from('subcontractor_invoice_items').insert(
|
||||
valid.map((item, idx) => ({
|
||||
invoice_id: inv.id,
|
||||
task_id: item.task_id || null,
|
||||
description: String(item.description).trim(),
|
||||
quantity: Number(item.quantity) || 1,
|
||||
unit_price: Number(item.unit_price) || 0,
|
||||
sort_order: idx,
|
||||
}))
|
||||
);
|
||||
if (itemsErr) throw itemsErr;
|
||||
|
||||
const invoiceTotal = valid.reduce((sum, item) => sum + (Number(item.unit_price) || 0) * (Number(item.quantity) || 1), 0);
|
||||
sendEmail('subcontractor_invoice_submitted', 'hello@fourgebranding.com', {
|
||||
subName: currentUser.name,
|
||||
subEmail: currentUser.email || '',
|
||||
invoiceNumber: inv.invoice_number,
|
||||
total: invoiceTotal.toFixed(2),
|
||||
invoiceId: inv.id,
|
||||
}).catch((err) => console.error('Sub invoice notification failed:', err));
|
||||
|
||||
if (onSubmitted) {
|
||||
await onSubmitted(inv);
|
||||
} else {
|
||||
navigate(`/subs-invoices/${inv.id}`);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err?.message || 'Failed to submit invoice.');
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, gap: 0 }}>
|
||||
{!embedded && (
|
||||
<>
|
||||
<button className="back-link" onClick={handleCancel}>← 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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{embedded ? (
|
||||
<div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, flex: '0 0 260px', overflowY: 'auto' }}>
|
||||
<div style={{ ...FIELD_LABEL_STYLE, marginBottom: 3 }}>New Invoice</div>
|
||||
<div>
|
||||
<div style={{ ...FIELD_LABEL_STYLE, marginBottom: 3 }}>Invoice Date</div>
|
||||
<input type="text" value={new Date(INVOICE_TODAY).toLocaleDateString()} readOnly style={FINANCE_MODAL_INPUT_STYLE} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ ...FIELD_LABEL_STYLE, marginBottom: 3 }}>Invoice Number</div>
|
||||
<input type="text" value={invoiceNumber} onChange={(e) => setInvoiceNumber(e.target.value)} style={FINANCE_MODAL_INPUT_STYLE} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ ...FIELD_LABEL_STYLE, marginBottom: 3 }}>Notes</div>
|
||||
<textarea
|
||||
placeholder="Additional notes for the team..."
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
style={FINANCE_MODAL_TEXTAREA_STYLE}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginTop: 'auto', paddingTop: 8 }}>
|
||||
<div style={{ ...FIELD_LABEL_STYLE, marginBottom: 3 }}>Total</div>
|
||||
<div style={FINANCE_MODAL_TOTAL_VALUE_STYLE}>${total.toFixed(2)}</div>
|
||||
</div>
|
||||
{error && <div style={{ fontSize: 12, color: 'var(--danger)' }}>{error}</div>}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
|
||||
<div style={{ ...FIELD_LABEL_STYLE, marginBottom: 3 }}>Completed Tasks</div>
|
||||
{completedTasks.length > 0 && !loadingTasks && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
style={{ fontSize: 11 }}
|
||||
onClick={() => completedTasks.forEach((task) => { if (!addedTaskIds.has(task.key)) addTask(task); })}
|
||||
>
|
||||
+ All
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{loadingTasks ? (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>Loading…</div>
|
||||
) : completedTasks.length === 0 ? (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>No completed tasks available to invoice.</div>
|
||||
) : (
|
||||
completedTasks.map((task) => {
|
||||
const alreadyAdded = addedTaskIds.has(task.key);
|
||||
return (
|
||||
<div key={task.key} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 10px', background: 'var(--card-bg-2)', borderRadius: 4, border: '1px solid var(--border)', marginBottom: 4 }}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span className={`badge ${task.is_revision ? 'badge-client_revision' : 'badge-initial'}`}>
|
||||
{task.is_revision ? 'Revision' : 'New'}
|
||||
</span>
|
||||
<div style={{ fontSize: 12 }}>{task.project_name ? `${task.project_name} • ` : ''}{task.title} · {versionLabel(task.version_number)}</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
{task.version_number === 0
|
||||
? `$${rate.toFixed(2)}`
|
||||
: task.version_number === 1
|
||||
? 'Free'
|
||||
: `$${REVISION_RATE.toFixed(2)}`}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
style={{ fontSize: 11 }}
|
||||
onClick={() => !alreadyAdded && addTask(task)}
|
||||
disabled={alreadyAdded}
|
||||
>
|
||||
{alreadyAdded ? 'Added' : '+ Add'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={lineItemsRef}
|
||||
style={{
|
||||
flex: 1,
|
||||
border: '1px solid var(--border)',
|
||||
borderColor: lineItemsPulse ? 'var(--accent)' : 'var(--border)',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
boxShadow: lineItemsPulse ? '0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent)' : 'none',
|
||||
transition: 'border-color 180ms ease, box-shadow 180ms ease',
|
||||
}}
|
||||
>
|
||||
<div style={{ ...FIELD_LABEL_STYLE, marginBottom: 6 }}>Line Items ({items.filter((item) => String(item.description || '').trim()).length})</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 60px 100px 90px 28px', gap: 6, marginBottom: 6 }}>
|
||||
{['', 'Type', 'Description', 'Qty', 'Unit Price', 'Total', ''].map((header, index) => (
|
||||
<div key={index} style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.5, color: 'var(--text-muted)', textAlign: index > 3 ? 'right' : 'left' }}>{header}</div>
|
||||
))}
|
||||
</div>
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={() => handleDrop(index)}
|
||||
style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 60px 100px 90px 28px', gap: 6, alignItems: 'center', marginBottom: 4 }}
|
||||
>
|
||||
<div draggable onDragStart={(e) => { dragItem.current = index; e.dataTransfer.effectAllowed = 'move'; }} style={{ cursor: 'grab', color: 'var(--text-muted)', fontSize: 12, textAlign: 'center', userSelect: 'none' }}>⠿</div>
|
||||
<span className={`badge ${item.is_revision ? 'badge-client_revision' : 'badge-initial'}`}>
|
||||
{item.is_revision ? 'Revision' : 'New'}
|
||||
</span>
|
||||
<input style={FINANCE_MODAL_LINE_ITEM_INPUT_STYLE} type="text" placeholder="Description…" value={item.description} onChange={(e) => updateItem(item.id, 'description', e.target.value)} />
|
||||
<input style={{ ...FINANCE_MODAL_NUMBER_INPUT_STYLE, textAlign: 'center' }} type="number" min="0.5" step="0.5" value={item.quantity} onChange={(e) => updateItem(item.id, 'quantity', e.target.value)} />
|
||||
<input style={{ ...FINANCE_MODAL_NUMBER_INPUT_STYLE, textAlign: 'right' }} type="number" min="0" step="0.01" placeholder="0.00" value={item.unit_price} onChange={(e) => updateItem(item.id, 'unit_price', e.target.value)} />
|
||||
<div style={{ minHeight: 32, display: 'flex', alignItems: 'center', justifyContent: 'flex-end', textAlign: 'right', fontSize: 13, color: 'var(--text-primary)', paddingRight: 2, fontVariantNumeric: 'tabular-nums' }}>${((Number(item.quantity) || 0) * (Number(item.unit_price) || 0)).toFixed(2)}</div>
|
||||
<button type="button" onClick={() => removeItem(item.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--danger)', fontSize: 14, padding: 2 }}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="btn btn-outline" style={{ marginTop: 8, fontSize: 12 }} onClick={() => { setItems((prev) => [...prev, newItem()]); focusLineItems(); }}>+ Line Item</button>
|
||||
</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.key)) addTask(task); })}
|
||||
>
|
||||
+ Add All
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{loadingTasks ? (
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading...</p>
|
||||
) : completedTasks.length === 0 ? (
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>No completed tasks available to invoice.</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{completedTasks.map((task) => {
|
||||
const alreadyAdded = addedTaskIds.has(task.key);
|
||||
return (
|
||||
<div key={task.key} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 400 }}>
|
||||
{task.project_name ? `${task.project_name} • ` : ''}{task.title} · {versionLabel(task.version_number)}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
{task.version_number === 0
|
||||
? `New Book · $${rate.toFixed(2)}`
|
||||
: task.version_number === 1
|
||||
? `Revision ${versionLabel(task.version_number)} · Free`
|
||||
: `Revision ${versionLabel(task.version_number)} · $${REVISION_RATE.toFixed(2)}`}
|
||||
</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
|
||||
ref={lineItemsRef}
|
||||
className="card"
|
||||
style={{
|
||||
marginBottom: 24,
|
||||
borderColor: lineItemsPulse ? 'var(--accent)' : 'var(--border)',
|
||||
boxShadow: lineItemsPulse ? '0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent)' : 'none',
|
||||
transition: 'border-color 180ms ease, box-shadow 180ms ease',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<div className="card-title" style={{ marginBottom: 0 }}>Line Items ({items.filter((item) => String(item.description || '').trim()).length})</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((header, index) => (
|
||||
<div key={index} style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', textAlign: index > 3 ? 'right' : 'left' }}>{header}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={() => handleDrop(index)}
|
||||
style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 80px 120px 120px 40px', gap: 8, alignItems: 'center' }}
|
||||
>
|
||||
<div draggable onDragStart={(e) => { dragItem.current = index; e.dataTransfer.effectAllowed = 'move'; }} style={{ cursor: 'grab', color: 'var(--text-muted)', fontSize: 14, textAlign: 'center', userSelect: 'none' }}>⠿</div>
|
||||
<span className={`badge ${item.is_revision ? 'badge-client_revision' : 'badge-initial'}`}>
|
||||
{item.is_revision ? 'Revision' : 'New'}
|
||||
</span>
|
||||
<input type="text" placeholder="Description..." value={item.description} onChange={(e) => updateItem(item.id, 'description', e.target.value)} style={{ margin: 0 }} />
|
||||
<input type="number" min="0.5" step="0.5" value={item.quantity} onChange={(e) => updateItem(item.id, 'quantity', e.target.value)} style={{ margin: 0, textAlign: 'center' }} />
|
||||
<input type="number" min="0" step="0.01" placeholder="0.00" value={item.unit_price} onChange={(e) => updateItem(item.id, 'unit_price', e.target.value)} style={{ margin: 0, textAlign: 'right' }} />
|
||||
<div style={{ textAlign: 'right', fontSize: 14, fontWeight: 400, color: 'var(--text-primary)', paddingRight: 4 }}>
|
||||
${((Number(item.quantity) || 0) * (Number(item.unit_price) || 0)).toFixed(2)}
|
||||
</div>
|
||||
<button onClick={() => removeItem(item.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--danger)', fontSize: 16, padding: 4 }}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button className="btn btn-outline btn-sm" style={{ marginTop: 12 }} onClick={() => { setItems((prev) => [...prev, newItem()]); focusLineItems(); }}>
|
||||
+ Add Line Item
|
||||
</button>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 20, paddingTop: 16, borderTop: '1px solid var(--border)' }}>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>Total</div>
|
||||
<div style={{ fontSize: 26, fontWeight: 400, color: 'var(--accent)' }}>${total.toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginBottom: 24 }}>
|
||||
<div className="card-title">Notes</div>
|
||||
<textarea
|
||||
placeholder="Additional notes for the team..."
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
style={{ minHeight: 80 }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={embedded ? 'modal-action-row' : 'action-buttons'} style={embedded ? { paddingTop: 14, marginTop: 14, flexShrink: 0 } : undefined}>
|
||||
<LoadingButton className="btn btn-primary" onClick={handleSubmit} loading={saving} loadingText="Submitting...">
|
||||
Submit Invoice
|
||||
</LoadingButton>
|
||||
<button className="btn btn-outline" onClick={handleCancel} disabled={saving}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useRefetchOnFocus } from './useRefetchOnFocus';
|
||||
import { useRealtimeSubscription } from './useRealtimeSubscription';
|
||||
|
||||
export function useLiveRefresh(tables = []) {
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const refresh = useCallback(() => setRefreshKey((key) => key + 1), []);
|
||||
useRefetchOnFocus(refresh);
|
||||
useRealtimeSubscription(tables, refresh);
|
||||
return { refreshKey, refresh };
|
||||
}
|
||||
+254
-286
@@ -42,15 +42,58 @@
|
||||
--overlay-scrim: rgba(0,0,0,0.58);
|
||||
--popup-shadow: 0 24px 64px rgba(0,0,0,0.5);
|
||||
--avatar-inner-ring: #111111;
|
||||
--bg-grid-line: rgba(245, 165, 35, 0.07);
|
||||
--bg-grid-glow: rgba(245, 165, 35, 0.08);
|
||||
--bg-grid-streak: rgba(255, 252, 244, 1);
|
||||
--danger: #ef4444;
|
||||
--success: #22c55e;
|
||||
--table-scroll-thumb: rgba(0,0,0,0.1);
|
||||
--table-scroll-thumb-hover: rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
@keyframes ui-fade-up {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
@keyframes ambient-grid-flow {
|
||||
0% {
|
||||
background-position:
|
||||
calc(42px - 4px) calc(-126px - 4px),
|
||||
calc(126px - 4px) calc(100vh + 126px - 4px),
|
||||
calc(-126px - 4px) calc(84px - 4px),
|
||||
calc(100vw + 126px - 4px) calc(168px - 4px),
|
||||
calc(294px - 4px) calc(-210px - 4px),
|
||||
calc(378px - 4px) calc(100vh + 168px - 4px),
|
||||
calc(-168px - 4px) calc(336px - 4px),
|
||||
calc(100vw + 168px - 4px) calc(420px - 4px),
|
||||
calc(546px - 4px) calc(-168px - 4px),
|
||||
calc(714px - 4px) calc(100vh + 210px - 4px);
|
||||
opacity: 0.2;
|
||||
}
|
||||
50% {
|
||||
background-position:
|
||||
calc(42px - 4px) calc(50vh - 4px),
|
||||
calc(126px - 4px) calc(50vh - 4px),
|
||||
calc(50vw - 4px) calc(84px - 4px),
|
||||
calc(50vw - 4px) calc(168px - 4px),
|
||||
calc(294px - 4px) calc(50vh - 4px),
|
||||
calc(378px - 4px) calc(50vh - 4px),
|
||||
calc(50vw - 4px) calc(336px - 4px),
|
||||
calc(50vw - 4px) calc(420px - 4px),
|
||||
calc(546px - 4px) calc(50vh - 4px),
|
||||
calc(714px - 4px) calc(50vh - 4px);
|
||||
opacity: 0.38;
|
||||
}
|
||||
100% {
|
||||
background-position:
|
||||
calc(42px - 4px) calc(100vh + 126px - 4px),
|
||||
calc(126px - 4px) calc(-126px - 4px),
|
||||
calc(100vw + 126px - 4px) calc(84px - 4px),
|
||||
calc(-126px - 4px) calc(168px - 4px),
|
||||
calc(294px - 4px) calc(100vh + 210px - 4px),
|
||||
calc(378px - 4px) calc(-168px - 4px),
|
||||
calc(100vw + 168px - 4px) calc(336px - 4px),
|
||||
calc(-168px - 4px) calc(420px - 4px),
|
||||
calc(546px - 4px) calc(100vh + 168px - 4px),
|
||||
calc(714px - 4px) calc(-210px - 4px);
|
||||
opacity: 0.16;
|
||||
}
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
@@ -75,6 +118,9 @@
|
||||
--overlay-scrim: rgba(255,255,255,0.72);
|
||||
--popup-shadow: 0 16px 42px rgba(0,0,0,0.18);
|
||||
--avatar-inner-ring: #ffffff;
|
||||
--bg-grid-line: rgba(0, 0, 0, 0.06);
|
||||
--bg-grid-glow: rgba(0, 0, 0, 0.05);
|
||||
--bg-grid-streak: rgba(255, 255, 255, 1);
|
||||
--table-scroll-thumb: rgba(0,0,0,0.1);
|
||||
--table-scroll-thumb-hover: rgba(0,0,0,0.2);
|
||||
}
|
||||
@@ -90,8 +136,6 @@
|
||||
}
|
||||
[data-theme="light"] input::placeholder,
|
||||
[data-theme="light"] textarea::placeholder { color: #aaa; }
|
||||
[data-theme="light"] th { background: #fafafa; }
|
||||
[data-theme="light"] tr:hover td { background: #fafafa; }
|
||||
[data-theme="light"] .assign-select { background: #fff; color: #1a1a1a; }
|
||||
[data-theme="light"] .badge-not_started { background: #f1f5f9; color: #64748b; border-color: #e2e8f0; }
|
||||
[data-theme="light"] .badge-in_progress { background: #eff6ff; color: #2563eb; border-color: #bfdbfe; }
|
||||
@@ -128,9 +172,63 @@ body {
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
#root { all: unset; display: block; height: 100%; }
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
background-image:
|
||||
linear-gradient(to right, var(--bg-grid-line) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, var(--bg-grid-line) 1px, transparent 1px),
|
||||
radial-gradient(circle at 50% 0%, var(--bg-grid-glow) 0%, transparent 62%);
|
||||
background-size: 42px 42px, 42px 42px, 100% 100%;
|
||||
background-position: 0 0, 0 0, center top;
|
||||
mask-image: linear-gradient(180deg, rgba(0,0,0,1) 0%, rgba(0,0,0,0.92) 18%, rgba(0,0,0,0.58) 62%, rgba(0,0,0,0.2) 100%);
|
||||
-webkit-mask-image: linear-gradient(180deg, rgba(0,0,0,1) 0%, rgba(0,0,0,0.92) 18%, rgba(0,0,0,0.58) 62%, rgba(0,0,0,0.2) 100%);
|
||||
}
|
||||
|
||||
body::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
background-image:
|
||||
radial-gradient(circle, var(--bg-grid-streak) 0 1.64px, rgba(255,255,255,1) 2.02px, transparent 4.15px),
|
||||
radial-gradient(circle, color-mix(in srgb, var(--bg-grid-streak) 99%, transparent) 0 1.58px, rgba(255,255,255,0.98) 1.96px, transparent 4px),
|
||||
radial-gradient(circle, color-mix(in srgb, var(--bg-grid-streak) 97%, transparent) 0 1.54px, rgba(255,255,255,0.94) 1.9px, transparent 3.88px),
|
||||
radial-gradient(circle, color-mix(in srgb, var(--bg-grid-streak) 95%, transparent) 0 1.5px, rgba(255,255,255,0.9) 1.84px, transparent 3.74px),
|
||||
radial-gradient(circle, color-mix(in srgb, var(--bg-grid-streak) 98%, transparent) 0 1.6px, rgba(255,255,255,0.98) 1.98px, transparent 4.06px),
|
||||
radial-gradient(circle, color-mix(in srgb, var(--bg-grid-streak) 94%, transparent) 0 1.48px, rgba(255,255,255,0.88) 1.8px, transparent 3.66px),
|
||||
radial-gradient(circle, color-mix(in srgb, var(--bg-grid-streak) 96%, transparent) 0 1.52px, rgba(255,255,255,0.92) 1.88px, transparent 3.8px),
|
||||
radial-gradient(circle, color-mix(in srgb, var(--bg-grid-streak) 100%, transparent) 0 1.66px, rgba(255,255,255,1) 2.04px, transparent 4.18px),
|
||||
radial-gradient(circle, color-mix(in srgb, var(--bg-grid-streak) 97%, transparent) 0 1.56px, rgba(255,255,255,0.95) 1.92px, transparent 3.92px),
|
||||
radial-gradient(circle, color-mix(in srgb, var(--bg-grid-streak) 93%, transparent) 0 1.46px, rgba(255,255,255,0.86) 1.76px, transparent 3.56px);
|
||||
background-size: 8px 8px, 8px 8px, 8px 8px, 8px 8px, 8px 8px, 8px 8px, 8px 8px, 8px 8px, 8px 8px, 8px 8px;
|
||||
background-repeat: no-repeat;
|
||||
filter: blur(0.22px);
|
||||
mix-blend-mode: screen;
|
||||
mask-image:
|
||||
linear-gradient(to right, #000 0 1px, transparent 1px 42px),
|
||||
linear-gradient(to bottom, #000 0 1px, transparent 1px 42px),
|
||||
linear-gradient(180deg, rgba(0,0,0,1) 0%, rgba(0,0,0,0.94) 20%, rgba(0,0,0,0.62) 64%, rgba(0,0,0,0.22) 100%);
|
||||
mask-size: 42px 42px, 42px 42px, 100% 100%;
|
||||
mask-position: 0 0, 0 0, 0 0;
|
||||
-webkit-mask-image:
|
||||
linear-gradient(to right, #000 0 1px, transparent 1px 42px),
|
||||
linear-gradient(to bottom, #000 0 1px, transparent 1px 42px),
|
||||
linear-gradient(180deg, rgba(0,0,0,1) 0%, rgba(0,0,0,0.94) 20%, rgba(0,0,0,0.62) 64%, rgba(0,0,0,0.22) 100%);
|
||||
-webkit-mask-size: 42px 42px, 42px 42px, 100% 100%;
|
||||
-webkit-mask-position: 0 0, 0 0, 0 0;
|
||||
animation: ambient-grid-flow 9.5s linear infinite;
|
||||
}
|
||||
|
||||
#root { all: unset; display: block; height: 100%; position: relative; z-index: 1; }
|
||||
|
||||
/* Layout */
|
||||
.app-layout { display: flex; height: 100vh; overflow: hidden; }
|
||||
@@ -330,7 +428,6 @@ input.site-header-search[type="text"]:focus { border-color: var(--accent); }
|
||||
.site-header-search-icon { position: absolute; left: 13px; top: 50%; transform: translateY(-50%); color: var(--text-muted); pointer-events: none; display: flex; }
|
||||
.site-header-search-icon svg { width: 14px; height: 14px; }
|
||||
.site-header-dropdown { position: absolute; top: calc(100% + 6px); right: 0; width: 340px; background: var(--sidebar-bg); border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.4); z-index: 300; overflow: hidden; }
|
||||
.site-header-dropdown { animation: ui-fade-up var(--motion-fast) var(--motion-ease); }
|
||||
.site-header-dropdown-group { padding: 8px 12px 4px; font-size: 10px; font-weight: 500; letter-spacing: 0.8px; text-transform: uppercase; color: var(--text-muted); }
|
||||
.site-header-dropdown-item { display: flex; align-items: center; gap: 10px; padding: 9px 12px; cursor: pointer; font-size: 13px; color: var(--text-primary); border: 1px solid transparent; }
|
||||
.site-header-dropdown-item:hover { color: #fff; }
|
||||
@@ -343,7 +440,6 @@ input.site-header-search[type="text"]:focus { border-color: var(--accent); }
|
||||
.site-header-avatar-btn:hover { opacity: 0.85; }
|
||||
[data-theme="light"] .site-header-avatar-btn { border-color: #fff; }
|
||||
.site-header-avatar-menu { position: absolute; top: calc(100% + 8px); right: 0; background: var(--sidebar-bg); border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.4); z-index: 300; min-width: 160px; overflow: hidden; }
|
||||
.site-header-avatar-menu { animation: ui-fade-up var(--motion-fast) var(--motion-ease); }
|
||||
.site-header-avatar-item { padding: 10px 16px; cursor: pointer; font-size: 13px; color: var(--text-primary); display: block; width: 100%; text-align: left; background: none; border: 1px solid transparent; font-family: inherit; }
|
||||
.site-header-avatar-item:hover { color: #fff; }
|
||||
.site-header-avatar-divider { height: 1px; background: var(--border); margin: 4px 0; }
|
||||
@@ -693,41 +789,14 @@ input.site-header-search[type="text"]:focus { border-color: var(--accent); }
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* File Sharing */
|
||||
.file-browser {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.file-browser-progress {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
z-index: 10;
|
||||
background: transparent;
|
||||
border-radius: 4px 4px 0 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-browser-progress-bar {
|
||||
.shared-progress-bar {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
transition: width 0.2s ease;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.file-browser-progress-bar.indeterminate {
|
||||
.shared-progress-bar.indeterminate {
|
||||
width: 40% !important;
|
||||
animation: progress-slide 1.4s ease-in-out infinite;
|
||||
}
|
||||
@@ -737,168 +806,6 @@ input.site-header-search[type="text"]:focus { border-color: var(--accent); }
|
||||
100% { transform: translateX(350%); }
|
||||
}
|
||||
|
||||
.file-browser-dragging {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.file-browser-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--card-bg);
|
||||
}
|
||||
|
||||
.file-browser-breadcrumbs,
|
||||
.file-browser-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.file-breadcrumb {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card-bg-2);
|
||||
color: var(--text-primary);
|
||||
border-radius: 4px;
|
||||
padding: 7px 10px;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-breadcrumb::before {
|
||||
content: '/';
|
||||
color: var(--text-muted);
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.file-breadcrumb:hover {
|
||||
border-color: var(--interactive-hover-border);
|
||||
}
|
||||
|
||||
.file-browser-create {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 340px) auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--card-bg-2);
|
||||
}
|
||||
|
||||
.file-upload-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.file-row {
|
||||
display: grid;
|
||||
grid-template-columns: 34px minmax(180px, 2fr) minmax(90px, 120px) minmax(110px, 140px) minmax(220px, 1fr);
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 700px;
|
||||
min-height: 52px;
|
||||
padding: 0 16px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.file-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.file-row:not(.file-row-head):hover,
|
||||
.file-row-button:hover {
|
||||
background: var(--interactive-row-hover);
|
||||
}
|
||||
|
||||
|
||||
.file-row-head {
|
||||
min-height: 38px;
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
background: var(--card-bg);
|
||||
}
|
||||
|
||||
.file-row-button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--accent);
|
||||
background: var(--card-bg-2);
|
||||
}
|
||||
|
||||
.file-name,
|
||||
.file-name-button {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.file-name-button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-name-button:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.file-meta {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.file-row-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.file-action-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -932,75 +839,6 @@ input.site-header-search[type="text"]:focus { border-color: var(--accent); }
|
||||
background: var(--interactive-row-hover);
|
||||
}
|
||||
|
||||
.file-drop-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(17, 17, 17, 0.72);
|
||||
border: 2px dashed var(--accent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.file-drop-panel {
|
||||
min-width: 280px;
|
||||
max-width: min(420px, calc(100% - 40px));
|
||||
border: 1px solid rgba(245, 165, 35, 0.45);
|
||||
border-radius: 4px;
|
||||
background: var(--card-bg);
|
||||
padding: 28px;
|
||||
text-align: center;
|
||||
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.file-drop-icon {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 4px;
|
||||
background: var(--accent);
|
||||
color: #111;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
font-weight: 400;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.file-drop-title {
|
||||
font-size: 17px;
|
||||
font-weight: 400;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.file-drop-subtitle {
|
||||
margin-top: 5px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.file-browser-toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.file-browser-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.file-browser-create {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.file-row {
|
||||
grid-template-columns: 34px minmax(150px, 2fr) 90px 110px minmax(170px, 1fr);
|
||||
min-width: 700px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
@@ -1133,11 +971,10 @@ textarea {
|
||||
.table-wrapper::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--table-scroll-thumb-hover);
|
||||
}
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th { text-align: left; padding: 12px 16px; font-size: 10px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.8px; color: var(--text-muted); background: rgba(255,255,255,0.07); border-bottom: 1px solid var(--border); }
|
||||
td { padding: 14px 16px; border-bottom: 1px solid var(--border); font-size: 13px; color: var(--text-primary); }
|
||||
table { width: 100%; border-collapse: collapse; table-layout: fixed; }
|
||||
th { text-align: left; padding: 0 16px 12px; font-size: 10px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.6px; color: var(--text-muted); background: transparent; border: none; vertical-align: top; }
|
||||
td { padding: 5px 16px; border: none; font-size: 13px; color: var(--text-primary); background: transparent; }
|
||||
|
||||
tr:hover td { background: rgba(255,255,255,0.02); }
|
||||
.table-no-row-hover tr:hover td { background: transparent !important; }
|
||||
[data-theme="light"] .table-no-row-hover tr:hover td { background: transparent !important; }
|
||||
.table-no-row-hover tbody tr:hover .table-link:not(:hover) { color: inherit !important; }
|
||||
@@ -1149,6 +986,17 @@ tr:hover td { background: rgba(255,255,255,0.02); }
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
}
|
||||
button.table-link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.table-link:hover,
|
||||
.table-link:focus-visible {
|
||||
color: var(--accent) !important;
|
||||
@@ -1269,8 +1117,8 @@ textarea { resize: vertical; height: auto; min-height: 100px; padding: 8px 14px;
|
||||
select option { background: #222; color: #fff; }
|
||||
|
||||
/* Auth */
|
||||
.auth-page { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #0d0d0d; padding: 20px; }
|
||||
.auth-card { background: #1a1a1a; border-radius: 4px; border: 1px solid var(--border); padding: 40px; width: 100%; max-width: 440px; }
|
||||
.auth-page { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: transparent; padding: 20px; position: relative; z-index: 1; }
|
||||
.auth-card { background: var(--card-bg); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border-radius: 4px; border: 1px solid var(--border); padding: 40px; width: 100%; max-width: 440px; box-shadow: var(--popup-shadow); }
|
||||
.auth-logo { text-align: center; margin-bottom: 32px; }
|
||||
.auth-logo h1 { font-size: 22px; font-weight: 400; color: #ffffff; letter-spacing: -0.5px; }
|
||||
.auth-logo p { font-size: 13px; color: #888; margin-top: 6px; }
|
||||
@@ -1280,24 +1128,24 @@ select option { background: #222; color: #fff; }
|
||||
.auth-card input[type="password"],
|
||||
.auth-card select,
|
||||
.auth-card textarea {
|
||||
background: #222;
|
||||
border-color: #333;
|
||||
color: #fff;
|
||||
background: var(--card-bg-2);
|
||||
border-color: var(--border);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.auth-card input::placeholder,
|
||||
.auth-card textarea::placeholder { color: #555; }
|
||||
.auth-card textarea::placeholder { color: var(--text-muted); }
|
||||
.auth-card input:focus, .auth-card select:focus, .auth-card textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(245,165,35,0.1); }
|
||||
.auth-card .btn-outline { border-color: #333; color: #888; }
|
||||
.auth-card .btn-outline:hover { background: #222; color: #fff; }
|
||||
.auth-divider { display: flex; align-items: center; gap: 12px; margin: 20px 0; color: #444; font-size: 12px; }
|
||||
.auth-card .btn-outline { border-color: var(--border); color: var(--text-secondary); }
|
||||
.auth-card .btn-outline:hover { background: var(--card-bg-2); color: var(--text-primary); }
|
||||
.auth-divider { display: flex; align-items: center; gap: 12px; margin: 20px 0; color: var(--text-muted); font-size: 12px; }
|
||||
.auth-divider::before, .auth-divider::after { content: ''; flex: 1; height: 1px; background: var(--border); }
|
||||
.quick-login { margin-top: 20px; padding-top: 20px; border-top: 1px solid var(--border); }
|
||||
.quick-login-title { font-size: 10px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.5px; color: #555; margin-bottom: 10px; }
|
||||
.quick-login-title { font-size: 10px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); margin-bottom: 10px; }
|
||||
.quick-login-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.quick-login-btn { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border-radius: 4px; border: 1px solid #2a2a2a; background: #222; cursor: pointer; transition: background 0.15s; text-align: left; width: 100%; }
|
||||
.quick-login-btn:hover { background: #2a2a2a; }
|
||||
.quick-login-name { font-size: 13px; font-weight: 400; color: #fff; }
|
||||
.quick-login-email { font-size: 11px; color: #666; }
|
||||
.quick-login-btn { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border-radius: 4px; border: 1px solid var(--border); background: var(--card-bg-2); cursor: pointer; transition: background 0.15s; text-align: left; width: 100%; }
|
||||
.quick-login-btn:hover { background: color-mix(in srgb, var(--card-bg-2) 72%, var(--accent) 28%); }
|
||||
.quick-login-name { font-size: 13px; font-weight: 400; color: var(--text-primary); }
|
||||
.quick-login-email { font-size: 11px; color: var(--text-muted); }
|
||||
|
||||
/* Misc */
|
||||
.back-link { display: inline-flex; align-items: center; gap: 6px; color: var(--text-muted); text-decoration: none; font-size: 12px; font-weight: 500; margin-bottom: 20px; cursor: pointer; border: none; background: none; text-transform: uppercase; letter-spacing: 0.5px; font-family: inherit; }
|
||||
@@ -1325,6 +1173,104 @@ select option { background: #222; color: #fff; }
|
||||
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px; }
|
||||
.detail-item label { font-size: 10px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.6px; color: var(--text-muted); margin-bottom: 4px; }
|
||||
.detail-item p { font-size: 14px; color: var(--text-primary); font-weight: 500; }
|
||||
.invoice-detail-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.invoice-detail-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
.invoice-detail-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
.invoice-detail-card {
|
||||
margin: 0;
|
||||
padding: 18px 21px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.invoice-detail-card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.invoice-detail-card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.invoice-detail-section-title {
|
||||
margin-bottom: 16px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
}
|
||||
.invoice-detail-meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 18px 24px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.invoice-detail-meta-item label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.invoice-detail-meta-item p {
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-primary);
|
||||
font-weight: 400;
|
||||
}
|
||||
.invoice-detail-table tbody td {
|
||||
vertical-align: top;
|
||||
}
|
||||
.invoice-detail-cell {
|
||||
padding: 5px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.invoice-detail-total-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 16px;
|
||||
margin-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.invoice-detail-total-label {
|
||||
margin-bottom: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.invoice-detail-total-value {
|
||||
font-size: 24px;
|
||||
line-height: 1.1;
|
||||
font-weight: 400;
|
||||
color: var(--accent);
|
||||
}
|
||||
.invoice-detail-notes {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.notification { padding: 12px 16px; border-radius: 4px; font-size: 13px; font-weight: 500; margin-bottom: 20px; }
|
||||
.notification-success { background: rgba(34,197,94,0.1); color: #4ade80; border: 1px solid rgba(34,197,94,0.2); }
|
||||
.notification-info { background: rgba(37,99,235,0.1); color: #60a5fa; border: 1px solid rgba(37,99,235,0.2); }
|
||||
@@ -1475,6 +1421,20 @@ select option { background: #222; color: #fff; }
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.action-buttons { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.modal-action-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.modal-action-row > .btn,
|
||||
.modal-action-row > .btn-icon,
|
||||
.modal-action-row > a.btn {
|
||||
width: 132px;
|
||||
min-width: 132px;
|
||||
flex: 0 0 132px;
|
||||
justify-content: center;
|
||||
}
|
||||
.assign-select { height: var(--h-control); padding: 0 10px; border-radius: 4px; border: 1px solid var(--border); font-size: 13px; color: var(--text-primary); background: var(--card-bg-2); cursor: pointer; font-family: inherit; line-height: 1; }
|
||||
.assign-select option { background: #222; }
|
||||
.flex { display: flex; }
|
||||
@@ -1649,6 +1609,8 @@ select option { background: #222; color: #fff; }
|
||||
|
||||
/* Stack grids on mobile */
|
||||
.grid-2 { grid-template-columns: 1fr; }
|
||||
.invoice-detail-summary-grid { grid-template-columns: 1fr; }
|
||||
.invoice-detail-meta-grid { grid-template-columns: 1fr; gap: 16px; }
|
||||
.stats-grid { grid-template-columns: 1fr 1fr; }
|
||||
.dash-stat-grid { grid-template-columns: 1fr 1fr; }
|
||||
.detail-grid { grid-template-columns: 1fr 1fr; }
|
||||
@@ -1702,6 +1664,8 @@ select option { background: #222; color: #fff; }
|
||||
/* Shared section tabs (Tasks / Projects / Finances / Detail tabs) */
|
||||
.section-tab-btn {
|
||||
font-family: 'Fourge', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.2px;
|
||||
@@ -1713,22 +1677,26 @@ select option { background: #222; color: #fff; }
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
box-shadow: inset 0 -2px 0 transparent;
|
||||
transition: all 160ms;
|
||||
}
|
||||
.section-tab-btn:hover,
|
||||
.section-tab-btn:focus-visible {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
box-shadow: inset 0 -2px 0 var(--accent);
|
||||
outline: none;
|
||||
}
|
||||
button.section-tab-btn:hover,
|
||||
button.section-tab-btn:focus-visible {
|
||||
color: var(--accent) !important;
|
||||
border-bottom-color: var(--accent) !important;
|
||||
box-shadow: inset 0 -2px 0 var(--accent) !important;
|
||||
}
|
||||
.section-tab-btn.is-active {
|
||||
color: var(--text-primary);
|
||||
border-bottom-color: var(--accent);
|
||||
box-shadow: inset 0 -2px 0 var(--accent);
|
||||
}
|
||||
|
||||
/* Rebuilt hover system (single source of truth) */
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import jsPDF from 'jspdf';
|
||||
import { formatLongDate } from './dates';
|
||||
|
||||
function formatDate(dateStr) {
|
||||
return formatLongDate(dateStr ? `${dateStr}T12:00:00` : '', '-');
|
||||
}
|
||||
|
||||
// Letter landscape: 792 x 612 pt
|
||||
const W = 792;
|
||||
@@ -8,12 +13,6 @@ const ACCENT = [245, 165, 35];
|
||||
const DARK = [18, 18, 18];
|
||||
const HEADER_H = 64;
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
function formatCoverAddress(address) {
|
||||
const parts = String(address || '')
|
||||
.split(',')
|
||||
@@ -384,7 +383,7 @@ export async function generateBrandBookEditorPDF(data) {
|
||||
let pageNum = 0;
|
||||
const rev = String(revision || '01').padStart(2, '0');
|
||||
const displayDate = bookDate
|
||||
? new Date(bookDate + 'T12:00:00').toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
|
||||
? formatLongDate(`${bookDate}T12:00:00`, '-')
|
||||
: '';
|
||||
|
||||
// ─── COVER PAGE ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -8,6 +8,46 @@ export function formatDateEST(value) {
|
||||
});
|
||||
}
|
||||
|
||||
export function formatShortDate(value, fallback = '—') {
|
||||
if (!value) return fallback;
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return fallback;
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
export function formatLongDate(value, fallback = '—') {
|
||||
if (!value) return fallback;
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return fallback;
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
export function formatShortDateTime(value, fallback = '—') {
|
||||
if (!value) return fallback;
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return fallback;
|
||||
return `${formatShortDate(date, fallback)} ${date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
})}`;
|
||||
}
|
||||
|
||||
export function formatDateAtNoon(value, fallback = '—') {
|
||||
if (!value) return fallback;
|
||||
const date = new Date(`${value}T12:00:00`);
|
||||
if (Number.isNaN(date.getTime())) return fallback;
|
||||
return formatLongDate(date, fallback);
|
||||
}
|
||||
|
||||
export function parseDateOnly(value) {
|
||||
if (!value) return null;
|
||||
const [year, month, day] = String(value).split('-').map(Number);
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
import { supabase } from './supabase';
|
||||
|
||||
const FBQ_FN = `${import.meta.env.VITE_SUPABASE_URL}/functions/v1/fbq-proxy`;
|
||||
|
||||
async function fbq(op, path, extra = {}) {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session?.access_token) return;
|
||||
const headers = {
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
'X-Operation': op,
|
||||
'X-Path': path,
|
||||
...extra.headers,
|
||||
};
|
||||
await fetch(FBQ_FN, { method: 'POST', headers, body: extra.body ?? undefined }).catch(() => {});
|
||||
}
|
||||
|
||||
export function safeName(v) {
|
||||
return String(v || '').trim()
|
||||
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
export async function createProjectFolder(companyName, projectName) {
|
||||
if (!companyName || !projectName) return;
|
||||
const co = safeName(companyName);
|
||||
const proj = safeName(projectName);
|
||||
await fbq('mkdir', `/Clients/${co}/`);
|
||||
await fbq('mkdir', `/Clients/${co}/Projects/`);
|
||||
await fbq('mkdir', `/Clients/${co}/Projects/${proj}/`);
|
||||
await fbq('mkdir', `/Clients/${co}/Projects/${proj}/00 Project Files/`);
|
||||
}
|
||||
|
||||
export async function createTeamMemberFolder(name) {
|
||||
if (!name) return;
|
||||
await fbq('mkdir', '/Team/');
|
||||
await fbq('mkdir', `/Team/${safeName(name)}/`);
|
||||
}
|
||||
|
||||
export async function createTaskFolder(companyName, projectName, taskTitle) {
|
||||
if (!companyName || !projectName || !taskTitle) return;
|
||||
const co = safeName(companyName);
|
||||
const proj = safeName(projectName);
|
||||
const task = safeName(taskTitle);
|
||||
const base = `/Clients/${co}/Projects/${proj}/${task}`;
|
||||
const mkdirs = [
|
||||
`/Clients/${co}/`,
|
||||
`/Clients/${co}/Projects/`,
|
||||
`/Clients/${co}/Projects/${proj}/`,
|
||||
`/Clients/${co}/Projects/${proj}/00 Project Files/`,
|
||||
`${base}/`,
|
||||
`${base}/Request Info/`,
|
||||
`${base}/Old Books/`,
|
||||
];
|
||||
for (const dir of mkdirs) {
|
||||
await fbq('mkdir', dir);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createClientFolder(companyName) {
|
||||
if (!companyName) return;
|
||||
await fbq('mkdir', '/Clients/');
|
||||
await fbq('mkdir', `/Clients/${companyName}/`);
|
||||
}
|
||||
|
||||
export async function backfillClientFolders() {
|
||||
const { data } = await supabase.from('companies').select('name');
|
||||
if (!data?.length) return;
|
||||
await fbq('mkdir', '/Clients/');
|
||||
for (const company of data) {
|
||||
if (company.name) await fbq('mkdir', `/Clients/${company.name}/`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadFilesToRequestInfo(files, companyName, projectName, taskTitle, versionNumber = 0) {
|
||||
if (!files?.length || !companyName || !projectName || !taskTitle) return;
|
||||
|
||||
const co = safeName(companyName);
|
||||
const proj = safeName(projectName);
|
||||
const task = safeName(taskTitle);
|
||||
const rev = `R${String(versionNumber).padStart(2, '0')}`;
|
||||
const base = `/Clients/${co}/Projects/${proj}/${task}/Request Info/${rev}`;
|
||||
|
||||
const mkdirs = [
|
||||
`/Clients/${co}/`,
|
||||
`/Clients/${co}/Projects/`,
|
||||
`/Clients/${co}/Projects/${proj}/`,
|
||||
`/Clients/${co}/Projects/${proj}/${task}/`,
|
||||
`/Clients/${co}/Projects/${proj}/${task}/Request Info/`,
|
||||
`/Clients/${co}/Projects/${proj}/${task}/Working Files/`,
|
||||
`/Clients/${co}/Projects/${proj}/${task}/Old Books/`,
|
||||
`${base}/`,
|
||||
];
|
||||
for (const dir of mkdirs) {
|
||||
await fbq('mkdir', dir);
|
||||
}
|
||||
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session?.access_token) return;
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = `${base}/${file.name}`;
|
||||
const fd = new FormData(); fd.append('file', file);
|
||||
await fetch(FBQ_FN, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
'X-Operation': 'upload',
|
||||
'X-Path': filePath,
|
||||
},
|
||||
body: fd,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { supabase } from './supabase';
|
||||
|
||||
async function callFolderSync(action, body) {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
const accessToken = session?.access_token;
|
||||
if (!accessToken) return { ok: false, skipped: true, warning: 'No active session.' };
|
||||
|
||||
const response = await fetch(`/api/filebrowser?action=${encodeURIComponent(action)}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.error || 'Folder sync failed.');
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function ensureCompanyFolder({ companyId }) {
|
||||
if (!companyId) return { ok: false, skipped: true };
|
||||
return callFolderSync('ensure-company-folder', { companyId });
|
||||
}
|
||||
|
||||
export async function renameCompanyFolder({ companyId, oldName, newName }) {
|
||||
if (!companyId || !oldName || !newName) return { ok: false, skipped: true };
|
||||
return callFolderSync('rename-company-folder', { companyId, oldName, newName });
|
||||
}
|
||||
|
||||
export async function ensureProjectFolder({ projectId }) {
|
||||
if (!projectId) return { ok: false, skipped: true };
|
||||
return callFolderSync('ensure-project-folder', { projectId });
|
||||
}
|
||||
|
||||
export async function renameProjectFolder({ projectId, oldName, newName }) {
|
||||
if (!projectId || !oldName || !newName) return { ok: false, skipped: true };
|
||||
return callFolderSync('rename-project-folder', { projectId, oldName, newName });
|
||||
}
|
||||
|
||||
export async function ensureSubcontractorFolder({ profileId }) {
|
||||
if (!profileId) return { ok: false, skipped: true };
|
||||
return callFolderSync('ensure-profile-folder', { profileId });
|
||||
}
|
||||
|
||||
export async function renameSubcontractorFolder({ profileId, oldName, newName }) {
|
||||
if (!profileId || !oldName || !newName) return { ok: false, skipped: true };
|
||||
return callFolderSync('rename-profile-folder', { profileId, oldName, newName });
|
||||
}
|
||||
|
||||
export async function ensureRequestFolder({ projectId, taskTitle }) {
|
||||
if (!projectId || !taskTitle) return { ok: false, skipped: true };
|
||||
return callFolderSync('ensure-request-folder', { projectId, taskTitle });
|
||||
}
|
||||
|
||||
export async function uploadRequestFileToSurvey({ projectId, taskTitle, storagePath, fileName, bucket = 'submissions' }) {
|
||||
if (!projectId || !taskTitle || !storagePath || !fileName) return { ok: false, skipped: true };
|
||||
return callFolderSync('upload-request-file', { projectId, taskTitle, storagePath, fileName, bucket });
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export const CLIENT_APPROVED_OR_BETTER = new Set(['client_approved', 'invoiced', 'paid']);
|
||||
|
||||
export function isCompletedVersionEligible(task, versionNumber) {
|
||||
const taskVersion = Number(task?.current_version || 0);
|
||||
const version = Number(versionNumber || 0);
|
||||
if (version < taskVersion) return true;
|
||||
return version === taskVersion && CLIENT_APPROVED_OR_BETTER.has(task?.status);
|
||||
}
|
||||
|
||||
export function isInitialVersionEligible(task) {
|
||||
if (!task || task.invoiced) return false;
|
||||
return isCompletedVersionEligible(task, 0);
|
||||
}
|
||||
|
||||
export function getRevisionChargeQuantity(versionNumber) {
|
||||
const version = Number(versionNumber || 0);
|
||||
return version >= 2 ? 1 : 0;
|
||||
}
|
||||
@@ -1,9 +1,18 @@
|
||||
import { supabase } from './supabase';
|
||||
import { ensureProjectFolder } from './folderSync';
|
||||
|
||||
function normalizeProjectName(name = '') {
|
||||
return String(name).trim().toLowerCase();
|
||||
}
|
||||
|
||||
function serializeSubmissionSigns(normalizedSigns = []) {
|
||||
return normalizedSigns.map((s, i) => ({
|
||||
sign_number: i + 1,
|
||||
sign_name: s.signName,
|
||||
sign_family: s.signFamily || null,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function findOrCreateProject(companyId, projectName, knownProjects = []) {
|
||||
const normalized = normalizeProjectName(projectName);
|
||||
if (!companyId || !normalized) throw new Error('Project company and name are required.');
|
||||
@@ -31,7 +40,14 @@ export async function findOrCreateProject(companyId, projectName, knownProjects
|
||||
.select('id, name, company_id, status')
|
||||
.single();
|
||||
|
||||
if (!insertError && newProject) return newProject;
|
||||
if (!insertError && newProject) {
|
||||
try {
|
||||
await ensureProjectFolder({ projectId: newProject.id });
|
||||
} catch (folderError) {
|
||||
console.error('Project folder sync failed:', folderError);
|
||||
}
|
||||
return newProject;
|
||||
}
|
||||
|
||||
if (insertError?.code === '23505') {
|
||||
const { data: retryProjects, error: retryError } = await supabase
|
||||
@@ -88,6 +104,20 @@ export async function createInitialSubmissionForRequest({
|
||||
submittedBy,
|
||||
submittedByName,
|
||||
}) {
|
||||
const normalizedSignCount = Number.isFinite(Number(signCount)) ? Number(signCount) : null;
|
||||
const normalizedSigns = Array.isArray(signs)
|
||||
? signs
|
||||
.map((sign = {}) => ({
|
||||
signName: String(sign.signName || '').trim(),
|
||||
signFamily: String(sign.signFamily || '').trim(),
|
||||
}))
|
||||
.filter(sign => sign.signName || sign.signFamily)
|
||||
: [];
|
||||
|
||||
if ((normalizedSignCount || 0) > 0 && normalizedSigns.length !== normalizedSignCount) {
|
||||
throw new Error('Please fill in the sign information for each sign before submitting.');
|
||||
}
|
||||
|
||||
const { data: submission, error } = await supabase
|
||||
.from('submissions')
|
||||
.insert({
|
||||
@@ -98,7 +128,8 @@ export async function createInitialSubmissionForRequest({
|
||||
is_hot: isHot,
|
||||
service_type: serviceType,
|
||||
sign_family: signFamily || null,
|
||||
sign_count: signCount || null,
|
||||
sign_count: normalizedSignCount || null,
|
||||
signs: serializeSubmissionSigns(normalizedSigns),
|
||||
deadline: deadline || null,
|
||||
description,
|
||||
submitted_by: submittedBy,
|
||||
@@ -107,19 +138,7 @@ export async function createInitialSubmissionForRequest({
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (!error && submission) {
|
||||
if (signs?.length > 0) {
|
||||
await supabase.from('submission_signs').insert(
|
||||
signs.map((s, i) => ({
|
||||
submission_id: submission.id,
|
||||
sign_number: i + 1,
|
||||
sign_name: s.signName,
|
||||
sign_family: s.signFamily || null,
|
||||
}))
|
||||
);
|
||||
}
|
||||
return { submission, duplicate: false };
|
||||
}
|
||||
if (!error && submission) return { submission, duplicate: false };
|
||||
|
||||
if (error?.code === '23505' && requestKey) {
|
||||
const { data: existingSubmission, error: existingError } = await supabase
|
||||
@@ -128,7 +147,17 @@ export async function createInitialSubmissionForRequest({
|
||||
.eq('request_key', requestKey)
|
||||
.single();
|
||||
if (existingError) throw existingError;
|
||||
if (existingSubmission) return { submission: existingSubmission, duplicate: true };
|
||||
if (existingSubmission) {
|
||||
const existingSigns = Array.isArray(existingSubmission.signs) ? existingSubmission.signs : [];
|
||||
if (normalizedSigns.length > 0 && existingSigns.length === 0) {
|
||||
const { error: repairError } = await supabase
|
||||
.from('submissions')
|
||||
.update({ signs: serializeSubmissionSigns(normalizedSigns) })
|
||||
.eq('id', existingSubmission.id);
|
||||
if (repairError) throw repairError;
|
||||
}
|
||||
return { submission: existingSubmission, duplicate: true };
|
||||
}
|
||||
}
|
||||
|
||||
throw error || new Error('Failed to create submission.');
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
export function mergeSubmissionDisplayNames(submissions = [], profiles = []) {
|
||||
const nameById = new Map(
|
||||
(profiles || [])
|
||||
.filter((profile) => profile?.id)
|
||||
.map((profile) => [profile.id, profile.name || ''])
|
||||
);
|
||||
|
||||
return (submissions || []).map((submission) => ({
|
||||
...submission,
|
||||
display_submitted_by_name: nameById.get(submission?.submitted_by) || submission?.submitted_by_name || '',
|
||||
}));
|
||||
}
|
||||
|
||||
export function getSubmissionDisplayName(submission) {
|
||||
return submission?.display_submitted_by_name || submission?.submitted_by_name || '';
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import jsPDF from 'jspdf';
|
||||
import { formatDateAtNoon } from './dates';
|
||||
|
||||
const W = 792;
|
||||
const H = 612;
|
||||
@@ -8,9 +9,7 @@ const DARK = [18, 18, 18];
|
||||
const HEADER_H = 64;
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
const d = new Date(`${dateStr}T12:00:00`);
|
||||
return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
|
||||
return formatDateAtNoon(dateStr, '-');
|
||||
}
|
||||
|
||||
async function blobToDataUrl(blob) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { sendEmail } from './email';
|
||||
|
||||
export async function sendTaskStatusUpdate({
|
||||
taskId,
|
||||
taskTitle,
|
||||
projectId,
|
||||
projectName = '',
|
||||
companyId,
|
||||
companyName = '',
|
||||
assignedProfileId,
|
||||
subject,
|
||||
headline,
|
||||
statusLabel,
|
||||
message,
|
||||
buttonLabel = 'View Task',
|
||||
includeTeam = false,
|
||||
includeAssigned = false,
|
||||
includeClient = false,
|
||||
includeProjectMembers = false,
|
||||
recipientStrategy = '',
|
||||
}) {
|
||||
if (!taskId || !taskTitle || !statusLabel || !message) return;
|
||||
return sendEmail('task_status_update', [], {
|
||||
taskId,
|
||||
taskTitle,
|
||||
projectId,
|
||||
projectName,
|
||||
companyId,
|
||||
companyName,
|
||||
assignedProfileId,
|
||||
subject,
|
||||
headline,
|
||||
statusLabel,
|
||||
message,
|
||||
buttonLabel,
|
||||
includeTeam,
|
||||
includeAssigned,
|
||||
includeClient,
|
||||
includeProjectMembers,
|
||||
recipientStrategy,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { popupSurfaceStyle } from './popupStyles';
|
||||
|
||||
export const TASK_TABLE_TH_STYLE = {
|
||||
fontSize: 10,
|
||||
fontWeight: 500,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.6,
|
||||
color: 'var(--text-muted)',
|
||||
textAlign: 'left',
|
||||
padding: '0 0 12px 5px',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
verticalAlign: 'top',
|
||||
};
|
||||
|
||||
export const TASK_TABLE_TD_BASE = {
|
||||
padding: '5px',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
};
|
||||
|
||||
export const TASK_MODAL_TITLE_STYLE = {
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
color: 'var(--text-secondary)',
|
||||
};
|
||||
|
||||
export const TASK_MODAL_LABEL_STYLE = {
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
color: 'var(--text-secondary)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
marginBottom: 6,
|
||||
};
|
||||
|
||||
export const TASK_MODAL_INPUT_STYLE = {
|
||||
fontSize: 13,
|
||||
padding: '0 5px',
|
||||
textAlign: 'left',
|
||||
lineHeight: 1,
|
||||
};
|
||||
|
||||
export const TASK_MODAL_BUTTON_STYLE = {
|
||||
lineHeight: 1,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
};
|
||||
|
||||
export const TASK_MODAL_SHELL_STYLE = {
|
||||
...popupSurfaceStyle,
|
||||
background: 'var(--card-bg)',
|
||||
width: 'min(720px, 96vw)',
|
||||
maxHeight: 'calc(100vh - 48px)',
|
||||
overflowY: 'auto',
|
||||
};
|
||||
|
||||
export const PROJECT_MODAL_SHELL_STYLE = {
|
||||
...popupSurfaceStyle,
|
||||
background: 'var(--card-bg)',
|
||||
width: 'min(434px, 100%)',
|
||||
maxHeight: 'calc(100vh - 48px)',
|
||||
overflowY: 'auto',
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import { getCurrentVersionForTask, getDeadlineSourceSubmission } from './taskDeadlines';
|
||||
import { serviceTypes } from '../data/mockData';
|
||||
|
||||
export const REJECT_NOTE_PREFIX = '[rejected-note]';
|
||||
export const REVIEW_SHADOW_PREFIX = '[review-shadow]';
|
||||
const SERVICE_TYPE_SET = new Set(serviceTypes);
|
||||
|
||||
function isRecognizedServiceType(value) {
|
||||
const normalized = String(value || '').trim();
|
||||
return normalized ? SERVICE_TYPE_SET.has(normalized) : false;
|
||||
}
|
||||
|
||||
export function parseRejectedNote(body = '') {
|
||||
if (!String(body).startsWith(REJECT_NOTE_PREFIX)) return null;
|
||||
const rest = String(body).slice(REJECT_NOTE_PREFIX.length);
|
||||
const [versionRaw, ...noteParts] = rest.split('::');
|
||||
const versionNumber = Number(versionRaw);
|
||||
if (!Number.isFinite(versionNumber)) return null;
|
||||
return { versionNumber, body: noteParts.join('::').trim() };
|
||||
}
|
||||
|
||||
export function encodeRejectedNote(versionNumber, body) {
|
||||
return `${REJECT_NOTE_PREFIX}${versionNumber}::${body}`;
|
||||
}
|
||||
|
||||
export function isReviewShadowSubmission(submission) {
|
||||
return String(submission?.description || '').startsWith(REVIEW_SHADOW_PREFIX);
|
||||
}
|
||||
|
||||
export function isReviewShadowDescription(description = '') {
|
||||
return String(description).startsWith(REVIEW_SHADOW_PREFIX);
|
||||
}
|
||||
|
||||
export function getVisibleTaskSubmissions(submissions = []) {
|
||||
return (submissions || []).filter(submission => !isReviewShadowSubmission(submission));
|
||||
}
|
||||
|
||||
export function getLatestVisibleSubmissionForVersion(taskId, submissions = [], versionNumber = 0) {
|
||||
return getVisibleTaskSubmissions(submissions)
|
||||
.filter(submission => submission?.task_id === taskId && (submission?.version_number || 0) === versionNumber)
|
||||
.sort((a, b) => new Date(b.submitted_at || 0).getTime() - new Date(a.submitted_at || 0).getTime())[0] || null;
|
||||
}
|
||||
|
||||
export function getLatestDeliveryForVersion(taskId, submissions = [], deliveries = [], versionNumber = 0) {
|
||||
const versionSubmissionIds = new Set(
|
||||
(submissions || [])
|
||||
.filter(submission => submission?.task_id === taskId && (submission?.version_number || 0) === versionNumber)
|
||||
.map(submission => submission.id)
|
||||
);
|
||||
if (versionSubmissionIds.size === 0) return null;
|
||||
return (deliveries || [])
|
||||
.filter(delivery => versionSubmissionIds.has(delivery?.submission_id))
|
||||
.sort((a, b) => new Date(b.sent_at || 0).getTime() - new Date(a.sent_at || 0).getTime())[0] || null;
|
||||
}
|
||||
|
||||
export function getTaskDerivedState(task, submissions = [], deliveries = []) {
|
||||
const taskSubs = (submissions || []).filter(submission => submission?.task_id === task?.id);
|
||||
const visibleTaskSubs = getVisibleTaskSubmissions(taskSubs);
|
||||
const currentVersion = getCurrentVersionForTask(task, taskSubs);
|
||||
const deadlineSource = getDeadlineSourceSubmission(task, taskSubs);
|
||||
const latestVisibleSubmission = getLatestVisibleSubmissionForVersion(task?.id, taskSubs, currentVersion);
|
||||
const latestDelivery = getLatestDeliveryForVersion(task?.id, taskSubs, deliveries, currentVersion);
|
||||
const initialSubmission = visibleTaskSubs.find(submission => submission?.type === 'initial') || null;
|
||||
const canonicalServiceType =
|
||||
(isRecognizedServiceType(initialSubmission?.service_type) && initialSubmission?.service_type) ||
|
||||
visibleTaskSubs.find(submission => isRecognizedServiceType(submission?.service_type))?.service_type ||
|
||||
(isRecognizedServiceType(deadlineSource?.service_type) && deadlineSource?.service_type) ||
|
||||
'—';
|
||||
const deadline = deadlineSource?.deadline || latestVisibleSubmission?.deadline || null;
|
||||
const isHot = Boolean(deadlineSource?.is_hot || latestVisibleSubmission?.is_hot);
|
||||
const latestActivityAt = Math.max(
|
||||
latestVisibleSubmission?.submitted_at ? new Date(latestVisibleSubmission.submitted_at).getTime() : 0,
|
||||
latestDelivery?.sent_at ? new Date(latestDelivery.sent_at).getTime() : 0,
|
||||
task?.submitted_at ? new Date(task.submitted_at).getTime() : 0
|
||||
);
|
||||
|
||||
return {
|
||||
taskSubs,
|
||||
visibleTaskSubs,
|
||||
currentVersion,
|
||||
deadlineSource,
|
||||
latestVisibleSubmission,
|
||||
latestDelivery,
|
||||
initialSubmission,
|
||||
serviceType: canonicalServiceType,
|
||||
deadline,
|
||||
isHot,
|
||||
latestActivityAt,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { supabase } from './supabase';
|
||||
|
||||
export function getCurrentUserCompanyIds(currentUser) {
|
||||
return [
|
||||
...(currentUser?.companies?.map((company) => company?.company?.id || company?.id) || []),
|
||||
...(currentUser?.company_id ? [currentUser.company_id] : []),
|
||||
...(currentUser?.company?.id ? [currentUser.company.id] : []),
|
||||
].filter(Boolean).filter((value, index, list) => list.indexOf(value) === index);
|
||||
}
|
||||
|
||||
export async function resolveScopedWorkIds(currentUser, { isClient = false, isExternal = false } = {}) {
|
||||
let scopedCompanyIds = null;
|
||||
let scopedProjectIds = null;
|
||||
let scopedTaskIds = null;
|
||||
|
||||
if (isClient) {
|
||||
scopedCompanyIds = getCurrentUserCompanyIds(currentUser);
|
||||
if (scopedCompanyIds.length > 0) {
|
||||
const { data: projectRows } = await supabase
|
||||
.from('projects')
|
||||
.select('id, tasks(id)')
|
||||
.in('company_id', scopedCompanyIds);
|
||||
scopedProjectIds = (projectRows || []).map((project) => project.id).filter(Boolean);
|
||||
scopedTaskIds = (projectRows || [])
|
||||
.flatMap((project) => (project.tasks || []).map((task) => task.id))
|
||||
.filter(Boolean);
|
||||
} else {
|
||||
scopedProjectIds = [];
|
||||
scopedTaskIds = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (isExternal) {
|
||||
const { data: memberRows } = await supabase
|
||||
.from('project_members')
|
||||
.select('project_id')
|
||||
.eq('profile_id', currentUser?.id);
|
||||
scopedProjectIds = (memberRows || []).map((row) => row.project_id).filter(Boolean);
|
||||
if (scopedProjectIds.length > 0) {
|
||||
const { data: taskRows } = await supabase
|
||||
.from('tasks')
|
||||
.select('id')
|
||||
.in('project_id', scopedProjectIds);
|
||||
scopedTaskIds = (taskRows || []).map((row) => row.id).filter(Boolean);
|
||||
} else {
|
||||
scopedTaskIds = [];
|
||||
}
|
||||
}
|
||||
|
||||
return { scopedCompanyIds, scopedProjectIds, scopedTaskIds };
|
||||
}
|
||||
@@ -793,10 +793,11 @@ export default function BrandBook() {
|
||||
{loadingBooks ? (
|
||||
<p style={{ padding: '24px 0', color: 'var(--text-muted)' }}>Loading...</p>
|
||||
) : filteredBooks.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>{savedBooks.length === 0 ? 'No brand books yet' : 'No matching brand books'}</h3>
|
||||
<p>{savedBooks.length === 0 ? 'Create your first brand book to get started.' : 'Try clearing the current company filter.'}</p>
|
||||
<button className="btn btn-primary" onClick={handleNew} style={{ marginTop: 16 }}>+ New Brand Book</button>
|
||||
<div className="card" style={{ minHeight: 160, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12 }}>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)', textAlign: 'center' }}>
|
||||
{savedBooks.length === 0 ? 'No brand books' : 'No matching brand books'}
|
||||
</div>
|
||||
{savedBooks.length === 0 && <button className="btn btn-primary" onClick={handleNew}>+ New Brand Book</button>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
|
||||
+41
-182
@@ -1,13 +1,14 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import Layout from '../components/Layout';
|
||||
import PageLoader from '../components/PageLoader';
|
||||
import SortTh from '../components/SortTh';
|
||||
import { useSortable } from '../hooks/useSortable';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { deleteCompanyData } from '../lib/deleteHelpers';
|
||||
import { readPageCache, writePageCache } from '../lib/pageCache';
|
||||
import { createClientFolder, backfillClientFolders, createTeamMemberFolder } from '../lib/filebrowserFolders';
|
||||
import { ensureCompanyFolder, ensureSubcontractorFolder, renameSubcontractorFolder } from '../lib/folderSync';
|
||||
|
||||
// ── Team view ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -63,8 +64,11 @@ function TeamCompanies() {
|
||||
}).select().single();
|
||||
setSaving(false);
|
||||
if (data) {
|
||||
createClientFolder(data.name).catch(() => {});
|
||||
backfillClientFolders().catch(() => {});
|
||||
try {
|
||||
await ensureCompanyFolder({ companyId: data.id });
|
||||
} catch (folderError) {
|
||||
console.error('Company folder sync failed:', folderError);
|
||||
}
|
||||
setShowNew(false);
|
||||
setNewForm({ name: '', phone: '', address: '' });
|
||||
navigate(`/company/${data.id}`);
|
||||
@@ -80,8 +84,18 @@ function TeamCompanies() {
|
||||
|
||||
const handleEditUserSave = async (userId) => {
|
||||
if (!editUserVal.trim()) return;
|
||||
await supabase.from('profiles').update({ name: editUserVal.trim() }).eq('id', userId);
|
||||
setProfiles(prev => prev.map(u => u.id === userId ? { ...u, name: editUserVal.trim() } : u));
|
||||
const existingUser = profiles.find(u => u.id === userId);
|
||||
const oldName = existingUser?.name || '';
|
||||
const newName = editUserVal.trim();
|
||||
await supabase.from('profiles').update({ name: newName }).eq('id', userId);
|
||||
if (existingUser?.role === 'external') {
|
||||
try {
|
||||
await renameSubcontractorFolder({ profileId: userId, oldName, newName });
|
||||
} catch (folderError) {
|
||||
console.error('Subcontractor folder rename failed:', folderError);
|
||||
}
|
||||
}
|
||||
setProfiles(prev => prev.map(u => u.id === userId ? { ...u, name: newName } : u));
|
||||
setEditingUserId(null);
|
||||
};
|
||||
|
||||
@@ -113,15 +127,31 @@ function TeamCompanies() {
|
||||
const errBody = error?.context ? await error.context.json().catch(() => null) : null;
|
||||
const errMsg = errBody?.error || data?.error || error?.message;
|
||||
if (errMsg) { setUserError(errMsg); return; }
|
||||
if (userForm.role === 'external') {
|
||||
try {
|
||||
const { data: newProfile } = await supabase
|
||||
.from('profiles')
|
||||
.select('id')
|
||||
.eq('email', userForm.email.trim())
|
||||
.eq('role', 'external')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
if (newProfile?.id) {
|
||||
await ensureSubcontractorFolder({ profileId: newProfile.id });
|
||||
}
|
||||
} catch (folderError) {
|
||||
console.error('Subcontractor folder sync failed:', folderError);
|
||||
}
|
||||
}
|
||||
if (userForm.role === 'team' || userForm.role === 'external') {
|
||||
createTeamMemberFolder(userForm.name.trim()).catch(() => {});
|
||||
}
|
||||
setShowNewUser(false);
|
||||
setUserForm({ name: '', email: '', password: '', company_id: '', role: 'client' });
|
||||
load();
|
||||
};
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
if (loading) return <Layout><PageLoader /></Layout>;
|
||||
|
||||
const getProfileCompanyIds = (profile) => {
|
||||
const ids = new Set(
|
||||
@@ -209,7 +239,7 @@ function TeamCompanies() {
|
||||
)}
|
||||
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
{companies.length === 0 ? (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '8px 0' }}>No clients yet.</div>
|
||||
<div className="card-empty-center">No clients</div>
|
||||
) : (
|
||||
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
<table>
|
||||
@@ -363,7 +393,7 @@ function TeamCompanies() {
|
||||
</div>
|
||||
)}
|
||||
{clientProfiles.length === 0 ? (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '8px 0' }}>No users yet.</div>
|
||||
<div className="card-empty-center">No users</div>
|
||||
) : (
|
||||
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
<table>
|
||||
@@ -417,7 +447,7 @@ function TeamCompanies() {
|
||||
|
||||
{userSubTab === 'external' && <>
|
||||
{subcontractors.length === 0 ? (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '8px 0' }}>No subcontractors yet.</div>
|
||||
<div className="card-empty-center">No subcontractors</div>
|
||||
) : (
|
||||
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
<table>
|
||||
@@ -484,7 +514,7 @@ function ClientCompanyList() {
|
||||
</div>
|
||||
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
{companies.length === 0 ? (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '8px 0' }}>No companies linked to your account.</div>
|
||||
<div className="card-empty-center">No companies</div>
|
||||
) : (
|
||||
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
<table>
|
||||
@@ -512,177 +542,6 @@ function ClientCompanyList() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── (removed old ClientCompanies dropdown — kept for reference only) ──────────
|
||||
|
||||
function _UnusedClientCompanies() {
|
||||
const { currentUser } = useAuth();
|
||||
const companies = currentUser?.companies || [];
|
||||
const [selectedId, setSelectedId] = useState(companies[0]?.id || null);
|
||||
const company = companies.find(c => c.id === selectedId) || companies[0] || null;
|
||||
|
||||
const [members, setMembers] = useState([]);
|
||||
const [loading, setLoading] = useState(!!company?.id);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [form, setForm] = useState({ name: company?.name || '', phone: company?.phone || '', address: company?.address || '' });
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!company?.id) return;
|
||||
setForm({ name: company.name || '', phone: company.phone || '', address: company.address || '' });
|
||||
setEditing(false);
|
||||
setLoading(true);
|
||||
async function load() {
|
||||
const [{ data: primaryMembers }, { data: memberRows }] = await Promise.all([
|
||||
supabase.from('profiles').select('id, name, email').eq('company_id', company.id).in('role', ['client', 'external']),
|
||||
supabase.from('company_members').select('profile:profiles(id, name, email)').eq('company_id', company.id),
|
||||
]);
|
||||
const memberMap = new Map();
|
||||
(primaryMembers || []).forEach(m => memberMap.set(m.id, m));
|
||||
(memberRows || []).forEach(row => { if (row.profile) memberMap.set(row.profile.id, row.profile); });
|
||||
setMembers([...memberMap.values()]);
|
||||
setLoading(false);
|
||||
}
|
||||
load();
|
||||
}, [company?.id]);
|
||||
|
||||
const handleSave = async (e) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
const { error } = await supabase.from('companies').update({
|
||||
name: form.name.trim(),
|
||||
phone: form.phone.trim(),
|
||||
address: form.address.trim(),
|
||||
}).eq('id', company.id);
|
||||
setSaving(false);
|
||||
if (error) { alert('Failed to save. Please try again.'); return; }
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
if (!company) return (
|
||||
<Layout>
|
||||
<div className="page-header"><div className="page-title">My Company</div></div>
|
||||
<p style={{ color: 'var(--text-muted)' }}>No company linked to your account.</p>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
|
||||
const companyDetails = [
|
||||
{ label: 'Company Name', value: form.name || company.name || '—' },
|
||||
{ label: 'Phone', value: company.phone || '—' },
|
||||
{ label: 'Address', value: company.address || '—' },
|
||||
{ label: 'Members', value: String(members.length) },
|
||||
];
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
{companies.length > 1 ? (
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<select
|
||||
value={selectedId}
|
||||
onChange={e => setSelectedId(e.target.value)}
|
||||
style={{
|
||||
fontSize: 22, fontWeight: 400, background: 'var(--card-bg)',
|
||||
border: '1px solid var(--border)', borderRadius: 4,
|
||||
color: 'var(--text-primary)', cursor: 'pointer',
|
||||
padding: '4px 8px', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{companies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="page-title">{form.name || company.name}</div>
|
||||
)}
|
||||
<div className="page-subtitle">
|
||||
{[company.phone, company.address].filter(Boolean).join(' · ') || 'No contact info on file'}
|
||||
</div>
|
||||
</div>
|
||||
{!editing && (
|
||||
<button className="btn btn-outline" onClick={() => setEditing(true)}>Edit Info</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="stats-grid" style={{ marginBottom: 24 }}>
|
||||
{companyDetails.map(detail => (
|
||||
<div key={detail.label} className={`stat-card${detail.label === 'Members' ? ' stat-card-highlight' : ''}`}>
|
||||
<div className="stat-value" style={{ fontSize: detail.label === 'Members' ? 28 : 18 }}>{detail.value}</div>
|
||||
<div className="stat-label">{detail.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<div className="card" style={{ marginBottom: 24, maxWidth: 520 }}>
|
||||
<div className="card-title">Edit Company Info</div>
|
||||
<form onSubmit={handleSave}>
|
||||
<div className="form-group">
|
||||
<label>Company Name *</label>
|
||||
<input type="text" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} required />
|
||||
</div>
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Phone</label>
|
||||
<input type="text" placeholder="+1 (555) 000-0000" value={form.phone}
|
||||
onChange={e => setForm(f => ({ ...f, phone: e.target.value }))} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Address</label>
|
||||
<input type="text" placeholder="123 Main St, City, State" value={form.address}
|
||||
onChange={e => setForm(f => ({ ...f, address: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="action-buttons">
|
||||
<button type="submit" className="btn btn-primary" disabled={saving || !form.name.trim()}>
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-outline" onClick={() => {
|
||||
setEditing(false);
|
||||
setForm({ name: company.name || '', phone: company.phone || '', address: company.address || '' });
|
||||
}}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<div className="card-title">People</div>
|
||||
{members.length === 0 ? (
|
||||
<div className="card-empty-center">No members found.</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{members.map((member, i) => (
|
||||
<div key={member.id} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12, padding: '12px 0',
|
||||
borderBottom: i < members.length - 1 ? '1px solid var(--border)' : 'none',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: 4, background: 'var(--accent)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 13, fontWeight: 400, color: '#111', flexShrink: 0,
|
||||
}}>
|
||||
{member.name?.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 400, fontSize: 14, color: 'var(--text-primary)' }}>
|
||||
{member.name}
|
||||
{member.id === currentUser.id && (
|
||||
<span style={{ marginLeft: 8, fontSize: 11, color: 'var(--accent)', fontWeight: 500 }}>You</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{member.email || '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
export default function CompaniesPage() {
|
||||
|
||||
+21
-14
@@ -1,13 +1,14 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import Layout from '../components/Layout';
|
||||
import PageLoader from '../components/PageLoader';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { serviceTypes } from '../data/mockData';
|
||||
import { cleanupTaskStorage, deleteCompanyData } from '../lib/deleteHelpers';
|
||||
import { logActivity } from '../lib/activityLog';
|
||||
import { createProjectFolder } from '../lib/filebrowserFolders';
|
||||
import { ensureProjectFolder, renameCompanyFolder } from '../lib/folderSync';
|
||||
|
||||
export default function CompanyDetail() {
|
||||
const { id } = useParams();
|
||||
@@ -78,8 +79,14 @@ export default function CompanyDetail() {
|
||||
if (!nameVal.trim()) return;
|
||||
setSavingName(true);
|
||||
const oldName = company.name;
|
||||
await supabase.from('companies').update({ name: nameVal.trim() }).eq('id', id);
|
||||
setCompany(c => ({ ...c, name: nameVal.trim() }));
|
||||
const newName = nameVal.trim();
|
||||
await supabase.from('companies').update({ name: newName }).eq('id', id);
|
||||
try {
|
||||
await renameCompanyFolder({ companyId: id, oldName, newName });
|
||||
} catch (folderError) {
|
||||
console.error('Company folder rename failed:', folderError);
|
||||
}
|
||||
setCompany(c => ({ ...c, name: newName }));
|
||||
setEditingName(false);
|
||||
setSavingName(false);
|
||||
};
|
||||
@@ -179,8 +186,12 @@ export default function CompanyDetail() {
|
||||
status: 'active',
|
||||
}).select().single();
|
||||
if (data) {
|
||||
try {
|
||||
await ensureProjectFolder({ projectId: data.id });
|
||||
} catch (folderError) {
|
||||
console.error('Project folder sync failed:', folderError);
|
||||
}
|
||||
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'project_created', projectId: data.id, projectName: data.name });
|
||||
createProjectFolder(company.name, data.name).catch(() => {});
|
||||
setProjects(prev => [data, ...prev]);
|
||||
setNewProjectName('');
|
||||
setShowNewProject(false);
|
||||
@@ -248,7 +259,7 @@ export default function CompanyDetail() {
|
||||
setSavingSignFamilyPrice(null);
|
||||
};
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
if (loading) return <Layout><PageLoader /></Layout>;
|
||||
if (!company) return <Layout><p>Company not found.</p></Layout>;
|
||||
|
||||
const activeTasks = tasks.filter(t => t.status !== 'client_approved');
|
||||
@@ -318,15 +329,14 @@ export default function CompanyDetail() {
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 24, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 10, flexWrap: 'wrap', minHeight: 'var(--btn-height)' }}>
|
||||
{(isTeam ? ['users', 'projects', 'pricing'] : ['users', 'projects']).map(t => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`tab-btn${tab === t ? ' active' : ''}`}
|
||||
style={{ textTransform: 'capitalize' }}
|
||||
className={`section-tab-btn${tab === t ? ' is-active' : ''}`}
|
||||
>
|
||||
{t}
|
||||
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||
{t === 'users' && availableUsers.length > 0 && (
|
||||
<span style={{ marginLeft: 6, fontSize: 10, background: tab === t ? 'rgba(0,0,0,0.3)' : 'var(--danger)', color: 'white', padding: '1px 5px', borderRadius: 4, fontWeight: 400 }}>
|
||||
{availableUsers.length}
|
||||
@@ -342,7 +352,7 @@ export default function CompanyDetail() {
|
||||
<div className="card">
|
||||
<div className="card-title">Assigned Users</div>
|
||||
{users.length === 0 ? (
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>No users assigned to this company yet.</p>
|
||||
<div className="card-empty-center">No users</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{users.map((user, i) => (
|
||||
@@ -494,10 +504,7 @@ export default function CompanyDetail() {
|
||||
)}
|
||||
|
||||
{projects.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>No projects yet</h3>
|
||||
<p>Create a project to start adding jobs.</p>
|
||||
</div>
|
||||
<div className="card card-empty-center">No projects</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{projects.map(project => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import JSZip from 'jszip';
|
||||
import { heicTo, isHeic } from 'heic-to/csp';
|
||||
import Layout from '../components/Layout';
|
||||
import LoadingButton from '../components/LoadingButton';
|
||||
|
||||
@@ -13,6 +12,14 @@ const OUTPUT_FORMATS = [
|
||||
const HEIC_EXTENSIONS = new Set(['heic', 'heif']);
|
||||
const MAX_FILES = 100;
|
||||
|
||||
// Lazy-load heic-to (WASM, ~2.7MB) only when a conversion actually runs,
|
||||
// so visiting the page doesn't pull the whole library up front.
|
||||
let _heicLibPromise = null;
|
||||
function getHeicLib() {
|
||||
if (!_heicLibPromise) _heicLibPromise = import('heic-to/csp');
|
||||
return _heicLibPromise;
|
||||
}
|
||||
|
||||
function getExtension(name = '') {
|
||||
return name.split('.').pop()?.toLowerCase() || '';
|
||||
}
|
||||
@@ -96,6 +103,7 @@ async function convertRasterBlob(blob, format, quality) {
|
||||
}
|
||||
|
||||
async function convertHeicFile(file, format, quality) {
|
||||
const { heicTo } = await getHeicLib();
|
||||
if (format.mime === 'image/jpeg' || format.mime === 'image/png') {
|
||||
try {
|
||||
return await heicTo({ blob: file, type: format.mime, quality });
|
||||
@@ -129,7 +137,9 @@ async function convertHeicFile(file, format, quality) {
|
||||
|
||||
async function convertFile(file, format, quality) {
|
||||
const looksHeic = isHeicFile(file);
|
||||
const confirmedHeic = looksHeic ? await isHeic(file).catch(() => false) : false;
|
||||
const confirmedHeic = looksHeic
|
||||
? await getHeicLib().then(({ isHeic }) => isHeic(file)).catch(() => false)
|
||||
: false;
|
||||
if (looksHeic || confirmedHeic) {
|
||||
return convertHeicFile(file, format, quality);
|
||||
}
|
||||
@@ -368,7 +378,7 @@ export default function Converters() {
|
||||
</div>
|
||||
|
||||
{!files.length ? (
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 13 }}>No files added yet.</div>
|
||||
<div className="card-empty-center">No files</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
{files.map((file, index) => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useSearchParams } from 'react-router-dom';
|
||||
import PageLoader from '../components/PageLoader';
|
||||
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
||||
@@ -74,15 +75,14 @@ export default function PayInvoice() {
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: '#f5f5f5', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: 24 }}>
|
||||
{loading ? <PageLoader /> : null}
|
||||
<div style={{ width: '100%', maxWidth: 480 }}>
|
||||
{/* Logo */}
|
||||
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||
<img src="/fourge-logo.png" alt="Fourge Branding" style={{ height: 36, filter: 'invert(1)' }} />
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', color: '#666' }}>Loading...</div>
|
||||
) : !invoice ? (
|
||||
{!invoice ? (
|
||||
<div style={{ background: '#fff', color: '#141414', borderRadius: 4, padding: 32, textAlign: 'center', boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 400, marginBottom: 8 }}>Invoice not found</div>
|
||||
<div style={{ color: '#666' }}>This payment link may be invalid or expired.</div>
|
||||
|
||||
@@ -689,7 +689,7 @@ export default function ProfilePage() {
|
||||
<input type="text" value={editForm.linkedin} onChange={setEditField('linkedin')} placeholder="linkedin.com/in/username" style={modalInputStyle} />
|
||||
</div>
|
||||
{editError && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>{editError}</div>}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 6 }}>
|
||||
<div className="modal-action-row" style={{ marginTop: 6 }}>
|
||||
<button type="submit" className="btn btn-outline" disabled={savingProfile} style={modalButtonStyle}>
|
||||
{savingProfile ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
|
||||
+108
-166
@@ -1,34 +1,35 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import Layout from '../components/Layout';
|
||||
import PageLoader from '../components/PageLoader';
|
||||
import ProfileAvatar from '../components/ProfileAvatar';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import SortTh from '../components/SortTh';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { logActivity } from '../lib/activityLog';
|
||||
import { createTaskFolder, uploadFilesToRequestInfo } from '../lib/filebrowserFolders';
|
||||
import { createInitialSubmissionForRequest, createTaskForRequest } from '../lib/requestSubmission';
|
||||
import { ensureRequestFolder, renameProjectFolder, uploadRequestFileToSurvey } from '../lib/folderSync';
|
||||
import { sendEmail } from '../lib/email';
|
||||
import RequestForm from '../components/RequestForm';
|
||||
import { useSortable } from '../hooks/useSortable';
|
||||
import { serviceTypes } from '../data/mockData';
|
||||
import { addDaysToDateOnly, getTodayDateOnlyEST, fmtShortDate } from '../lib/dates';
|
||||
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
|
||||
import { useRefetchOnFocus } from '../hooks/useRefetchOnFocus';
|
||||
import { useRealtimeSubscription } from '../hooks/useRealtimeSubscription';
|
||||
import { fmtShortDate } from '../lib/dates';
|
||||
import { popupOverlayStyle } from '../lib/popupStyles';
|
||||
import { useLiveRefresh } from '../hooks/useLiveRefresh';
|
||||
import { getTaskDerivedState } from '../lib/taskVersions';
|
||||
import {
|
||||
TASK_TABLE_TH_STYLE,
|
||||
TASK_TABLE_TD_BASE,
|
||||
TASK_MODAL_TITLE_STYLE,
|
||||
TASK_MODAL_SHELL_STYLE,
|
||||
} from '../lib/taskUi';
|
||||
|
||||
const CARD = { padding: '18px 21px', borderRadius: 8 };
|
||||
const LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 14 };
|
||||
const META_LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 4 };
|
||||
const CARD_META_LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 2 };
|
||||
const MODAL_LBL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 6 };
|
||||
const TH_STYLE = { fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.6, color: 'var(--text-muted)', textAlign: 'left', padding: '0 0 12px 5px', border: 'none', background: 'transparent', verticalAlign: 'top' };
|
||||
const TD_BASE = { padding: '5px', border: 'none', background: 'transparent', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
|
||||
const MODAL_IN = { fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' };
|
||||
|
||||
const emptyJobForm = () => ({ title: '', serviceType: '', deadline: addDaysToDateOnly(getTodayDateOnlyEST(), 3), description: '', requestedBy: '' });
|
||||
|
||||
export default function ProjectDetailPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
@@ -38,28 +39,22 @@ export default function ProjectDetailPage() {
|
||||
const isExternal = currentUser?.role === 'external';
|
||||
const isTeam = currentUser?.role === 'team';
|
||||
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const refresh = useCallback(() => setRefreshKey(k => k + 1), []);
|
||||
useRefetchOnFocus(refresh);
|
||||
useRealtimeSubscription(['tasks', 'projects', 'project_members', 'profiles'], refresh);
|
||||
const { refreshKey } = useLiveRefresh(['tasks', 'projects', 'project_members', 'profiles']);
|
||||
|
||||
const [project, setProject] = useState(null);
|
||||
const [company, setCompany] = useState(null);
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [members, setMembers] = useState([]);
|
||||
const [externalProfiles, setExtProfs] = useState([]);
|
||||
const [companyUsers, setCompanyUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submissions, setSubmissions] = useState([]);
|
||||
const [deliveries, setDeliveries] = useState([]);
|
||||
const [activeTab, setActiveTab] = useState('all');
|
||||
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
const [nameVal, setNameVal] = useState('');
|
||||
const [savingName, setSavingName] = useState(false);
|
||||
|
||||
const [showAddJob, setShowAddJob] = useState(false);
|
||||
const [jobForm, setJobForm] = useState(emptyJobForm());
|
||||
const [savingJob, setSavingJob] = useState(false);
|
||||
const [showClientTask, setShowClientTask] = useState(false);
|
||||
const [clientTaskKey, setClientTaskKey] = useState(0);
|
||||
const [clientTaskSaving, setClientTaskSaving] = useState(false);
|
||||
@@ -93,13 +88,20 @@ export default function ProjectDetailPage() {
|
||||
if (taskIds.length > 0) {
|
||||
const { data: subs } = await supabase.from('submissions').select('id, task_id, type, version_number, service_type, deadline, is_hot, submitted_at').in('task_id', taskIds).order('submitted_at', { ascending: false });
|
||||
setSubmissions(subs || []);
|
||||
if ((subs || []).length > 0) {
|
||||
const { data: delRows } = await supabase.from('deliveries').select('id, submission_id, version_number, sent_at').in('submission_id', (subs || []).map(sub => sub.id));
|
||||
setDeliveries(delRows || []);
|
||||
} else {
|
||||
setDeliveries([]);
|
||||
}
|
||||
} else {
|
||||
setDeliveries([]);
|
||||
}
|
||||
const [{ data: users }, { data: pm }, { data: viaMembers }] = await Promise.all([
|
||||
supabase.from('profiles').select('id, name, avatar_url, email').eq('company_id', p.company_id).eq('role', 'client'),
|
||||
supabase.from('project_members').select('*, profile:profiles(id, name, avatar_url, email, role)').eq('project_id', id),
|
||||
supabase.from('company_members').select('profile:profiles(id, name, avatar_url, email, role)').eq('company_id', p.company_id),
|
||||
]);
|
||||
setCompanyUsers(users || []);
|
||||
setMembers(pm || []);
|
||||
const seen = new Set();
|
||||
const merged = [];
|
||||
@@ -128,8 +130,15 @@ export default function ProjectDetailPage() {
|
||||
e.preventDefault();
|
||||
if (!nameVal.trim()) return;
|
||||
setSavingName(true);
|
||||
await supabase.from('projects').update({ name: nameVal.trim() }).eq('id', id);
|
||||
setProject(p => ({ ...p, name: nameVal.trim() }));
|
||||
const oldName = project.name;
|
||||
const newName = nameVal.trim();
|
||||
await supabase.from('projects').update({ name: newName }).eq('id', id);
|
||||
try {
|
||||
await renameProjectFolder({ projectId: id, oldName, newName });
|
||||
} catch (folderError) {
|
||||
console.error('Project folder rename failed:', folderError);
|
||||
}
|
||||
setProject(p => ({ ...p, name: newName }));
|
||||
setEditingName(false);
|
||||
setSavingName(false);
|
||||
};
|
||||
@@ -151,23 +160,6 @@ export default function ProjectDetailPage() {
|
||||
setTasks(prev => prev.filter(t => t.id !== taskId));
|
||||
};
|
||||
|
||||
const handleAddJob = async (e) => {
|
||||
e.preventDefault();
|
||||
setSavingJob(true);
|
||||
const requestor = requesterOptions.find(u => u.id === jobForm.requestedBy);
|
||||
if (!requestor) { setSavingJob(false); return; }
|
||||
const { data: task } = await supabase.from('tasks').insert({ project_id: id, title: jobForm.title.trim(), status: 'not_started', current_version: 0 }).select().single();
|
||||
if (task) {
|
||||
await supabase.from('submissions').insert({ task_id: task.id, version_number: 0, type: 'initial', service_type: jobForm.serviceType, deadline: jobForm.deadline || null, description: jobForm.description.trim() || null, submitted_by: requestor.id, submitted_by_name: requestor.name.replace(' (You)', '') });
|
||||
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'task_created', taskId: task.id, taskTitle: task.title, projectId: id, projectName: project?.name });
|
||||
createTaskFolder(company?.name, project?.name, task.title).catch(() => {});
|
||||
setTasks(prev => [task, ...prev]);
|
||||
setJobForm(emptyJobForm());
|
||||
setShowAddJob(false);
|
||||
}
|
||||
setSavingJob(false);
|
||||
};
|
||||
|
||||
const handleAddMember = async () => {
|
||||
setSavingMembers(true);
|
||||
const currentIds = new Set(members.filter(m => m.profile?.role === 'external').map(m => m.profile_id));
|
||||
@@ -188,18 +180,12 @@ export default function ProjectDetailPage() {
|
||||
setSavingMembers(false);
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (profileId) => {
|
||||
await supabase.from('project_members').delete().eq('project_id', id).eq('profile_id', profileId);
|
||||
setMembers(prev => prev.filter(m => m.profile_id !== profileId));
|
||||
};
|
||||
|
||||
const handleClientTask = async (formData, files) => {
|
||||
if (clientTaskSaving) return;
|
||||
setClientTaskSaving(true); setClientTaskError('');
|
||||
try {
|
||||
const { task } = await createTaskForRequest({ projectId: project.id, title: formData.title.trim(), requestKey: clientTaskRequestKey });
|
||||
if (!task) throw new Error('Failed to create task.');
|
||||
createTaskFolder(company?.name, project.name, formData.title.trim()).catch(() => {});
|
||||
const { submission } = await createInitialSubmissionForRequest({
|
||||
taskId: task.id, requestKey: clientTaskRequestKey, isHot: formData.isHot,
|
||||
serviceType: formData.serviceType, signFamily: formData.signFamily,
|
||||
@@ -207,16 +193,32 @@ export default function ProjectDetailPage() {
|
||||
deadline: formData.deadline, description: formData.description,
|
||||
submittedBy: currentUser.id, submittedByName: currentUser.name,
|
||||
});
|
||||
try {
|
||||
await ensureRequestFolder({ projectId: project.id, taskTitle: formData.title.trim() });
|
||||
} catch (folderError) {
|
||||
console.error('Request folder sync failed:', folderError);
|
||||
}
|
||||
if (submission && files.length > 0) {
|
||||
for (const file of files) {
|
||||
const path = `${task.id}/${Date.now()}_${file.name}`;
|
||||
const path = `${task.id}/Survey/${Date.now()}_${file.name}`;
|
||||
const { data: up } = await supabase.storage.from('submissions').upload(path, file);
|
||||
if (up) await supabase.from('submission_files').insert({ submission_id: submission.id, name: file.name, storage_path: path, size: file.size });
|
||||
if (up) {
|
||||
await supabase.from('submission_files').insert({ submission_id: submission.id, name: file.name, storage_path: path, size: file.size });
|
||||
try {
|
||||
await uploadRequestFileToSurvey({
|
||||
projectId: project.id,
|
||||
taskTitle: formData.title.trim(),
|
||||
storagePath: path,
|
||||
fileName: file.name,
|
||||
});
|
||||
} catch (mirrorError) {
|
||||
console.error('Survey folder mirror failed:', mirrorError);
|
||||
}
|
||||
}
|
||||
}
|
||||
uploadFilesToRequestInfo(files, company?.name, project.name, formData.title.trim()).catch(() => {});
|
||||
}
|
||||
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'request_submitted', taskId: task.id, taskTitle: task.title, projectId: project.id, projectName: project.name }).catch(() => {});
|
||||
sendEmail('new_request', 'hello@fourgebranding.com', { clientName: currentUser.name, clientEmail: currentUser.email, company: company?.name || '', serviceType: formData.serviceType, projectName: project.name, deadline: formData.deadline, description: formData.description, taskId: task.id }).catch(() => {});
|
||||
sendEmail('new_request', 'hello@fourgebranding.com', { clientName: currentUser.name, clientEmail: currentUser.email, company: company?.name || '', serviceType: formData.serviceType, projectName: project.name, projectId: project.id, deadline: formData.deadline, description: formData.description, taskId: task.id }).catch(() => {});
|
||||
const { data: newTasks } = await supabase.from('tasks').select('*, assignee:profiles!assigned_to(avatar_url)').eq('project_id', id).order('submitted_at', { ascending: false });
|
||||
setTasks(newTasks || []);
|
||||
setShowClientTask(false); setClientTaskKey(k => k + 1); setClientTaskRequestKey(crypto.randomUUID());
|
||||
@@ -227,19 +229,14 @@ export default function ProjectDetailPage() {
|
||||
if (loading) return <Layout><PageLoader /></Layout>;
|
||||
if (!project) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Project not found.</p></Layout>;
|
||||
|
||||
const requesterOptions = [
|
||||
...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)` }] : []),
|
||||
...companyUsers.filter(u => u.id !== currentUser?.id),
|
||||
];
|
||||
|
||||
const rows = tasks.map(task => {
|
||||
const sub = submissions.find(s => s.task_id === task.id && s.type === 'initial') || submissions.find(s => s.task_id === task.id);
|
||||
const derived = getTaskDerivedState(task, submissions, deliveries);
|
||||
return {
|
||||
id: task.id, title: task.title, status: task.status,
|
||||
version: task.current_version || 0,
|
||||
version: derived.currentVersion,
|
||||
assignedName: task.assigned_name || null, assigneeAvatar: task.assignee?.avatar_url || null, assignedTo: task.assigned_to || null,
|
||||
serviceType: sub?.service_type || '—', deadline: sub?.deadline || null, isHot: sub?.is_hot || false,
|
||||
submittedAt: task.submitted_at || '',
|
||||
serviceType: derived.serviceType, deadline: derived.deadline, isHot: derived.isHot,
|
||||
submittedAt: derived.latestActivityAt ? new Date(derived.latestActivityAt).toISOString() : (task.submitted_at || ''),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -258,7 +255,8 @@ export default function ProjectDetailPage() {
|
||||
const doneStatuses = new Set(['client_approved', 'invoiced', 'paid']);
|
||||
const taskTabs = [
|
||||
{ id: 'all', label: 'All Tasks' },
|
||||
{ id: 'not_started', label: 'To Do' },
|
||||
{ id: 'new_requests', label: 'New Requests' },
|
||||
{ id: 'revisions', label: 'Revisions' },
|
||||
{ id: 'in_progress', label: 'In Progress' },
|
||||
{ id: 'on_hold', label: 'On Hold' },
|
||||
{ id: 'client_review', label: 'In Review' },
|
||||
@@ -267,6 +265,8 @@ export default function ProjectDetailPage() {
|
||||
];
|
||||
const visibleRows = activeTab === 'all' ? sortedRows
|
||||
: activeTab === 'completed' ? sortedRows.filter(r => doneStatuses.has(r.status))
|
||||
: activeTab === 'new_requests' ? sortedRows.filter(r => r.status === 'not_started' && Number(r.version || 0) === 0)
|
||||
: activeTab === 'revisions' ? sortedRows.filter(r => r.status === 'not_started' && Number(r.version || 0) >= 1)
|
||||
: activeTab === 'members' ? []
|
||||
: sortedRows.filter(r => r.status === activeTab);
|
||||
|
||||
@@ -345,12 +345,13 @@ export default function ProjectDetailPage() {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
|
||||
{(() => {
|
||||
const tabCounts = {
|
||||
all: tasks.length,
|
||||
not_started: tasks.filter(t => t.status === 'not_started').length,
|
||||
in_progress: tasks.filter(t => t.status === 'in_progress').length,
|
||||
on_hold: tasks.filter(t => t.status === 'on_hold').length,
|
||||
client_review: tasks.filter(t => t.status === 'client_review').length,
|
||||
completed: tasks.filter(t => ['client_approved','invoiced','paid'].includes(t.status)).length,
|
||||
all: rows.length,
|
||||
new_requests: rows.filter(r => r.status === 'not_started' && Number(r.version || 0) === 0).length,
|
||||
revisions: rows.filter(r => r.status === 'not_started' && Number(r.version || 0) >= 1).length,
|
||||
in_progress: rows.filter(r => r.status === 'in_progress').length,
|
||||
on_hold: rows.filter(r => r.status === 'on_hold').length,
|
||||
client_review: rows.filter(r => r.status === 'client_review').length,
|
||||
completed: rows.filter(r => ['client_approved','invoiced','paid'].includes(r.status)).length,
|
||||
members: members.filter(m => m.profile?.role === 'external').length,
|
||||
};
|
||||
return (
|
||||
@@ -375,7 +376,7 @@ export default function ProjectDetailPage() {
|
||||
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
{activeTab !== 'members' && (
|
||||
visibleRows.length === 0
|
||||
? <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 1, minHeight: 120, color: 'var(--text-muted)', fontSize: 13 }}>No tasks yet.</div>
|
||||
? <div className="card-empty-center">No tasks</div>
|
||||
: <div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden" style={{ overflowY: 'auto' }}>
|
||||
<table className="table-sticky-head" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
|
||||
<colgroup>
|
||||
@@ -389,44 +390,40 @@ export default function ProjectDetailPage() {
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="revision" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TH_STYLE}>R#</SortTh>
|
||||
<SortTh col="title" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TH_STYLE}>Name</SortTh>
|
||||
<SortTh col="assigned" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TH_STYLE, textAlign: 'center' }}>Assigned</SortTh>
|
||||
<SortTh col="priority" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TH_STYLE, textAlign: 'center' }}>Priority</SortTh>
|
||||
<SortTh col="serviceType" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TH_STYLE, textAlign: 'center' }}>Task Type</SortTh>
|
||||
<SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TH_STYLE, textAlign: 'center' }}>Deadline</SortTh>
|
||||
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TH_STYLE, textAlign: 'center' }}>Status</SortTh>
|
||||
<SortTh col="revision" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TASK_TABLE_TH_STYLE}>R#</SortTh>
|
||||
<SortTh col="title" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TASK_TABLE_TH_STYLE}>Name</SortTh>
|
||||
<SortTh col="assigned" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Assigned</SortTh>
|
||||
<SortTh col="priority" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Priority</SortTh>
|
||||
<SortTh col="serviceType" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Task Type</SortTh>
|
||||
<SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Deadline</SortTh>
|
||||
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Status</SortTh>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleRows.map(row => {
|
||||
const initials = row.assignedName ? row.assignedName.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase() : null;
|
||||
const avatarStyle = { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, borderRadius: '50%', overflow: 'hidden', verticalAlign: 'middle', flexShrink: 0 };
|
||||
return (
|
||||
<tr key={row.id}>
|
||||
<td style={{ ...TD_BASE, fontSize: 12, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>
|
||||
<Link to={`/tasks/${row.id}`} className="table-link">{`R${String(row.version).padStart(2, '0')}`}</Link>
|
||||
</td>
|
||||
<td style={{ ...TD_BASE, fontSize: 13, color: 'var(--text-primary)' }}>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)' }}>
|
||||
<Link to={`/tasks/${row.id}`} className="table-link">{row.title || '—'}</Link>
|
||||
</td>
|
||||
<td style={{ ...TD_BASE, textAlign: 'center' }}>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, textAlign: 'center' }}>
|
||||
{row.assignedName && row.assignedTo
|
||||
? <Link to={`/profile/${row.assignedTo}`} style={{ display: 'inline-flex' }}>
|
||||
{row.assigneeAvatar
|
||||
? <div title={row.assignedName} style={avatarStyle}><img src={row.assigneeAvatar} alt={row.assignedName} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /></div>
|
||||
: <div title={row.assignedName} style={{ ...avatarStyle, background: 'var(--accent)' }}><span style={{ fontSize: 10, fontWeight: 600, color: '#000', lineHeight: 1 }}>{initials}</span></div>
|
||||
}
|
||||
<ProfileAvatar name={row.assignedName} avatarUrl={row.assigneeAvatar} size={26} fontSize={10} style={{ verticalAlign: 'middle' }} />
|
||||
</Link>
|
||||
: <div title="Unassigned" style={{ ...avatarStyle, background: 'rgba(255,255,255,0.08)' }}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.3)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg></div>
|
||||
}
|
||||
</td>
|
||||
<td style={{ ...TD_BASE, fontSize: 11, textAlign: 'center', color: row.isHot ? '#ef4444' : 'var(--text-primary)', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 11, textAlign: 'center', color: row.isHot ? '#ef4444' : 'var(--text-primary)', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
{row.isHot ? 'HOT' : 'NO'}
|
||||
</td>
|
||||
<td style={{ ...TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>{row.serviceType}</td>
|
||||
<td style={{ ...TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>{fmtShortDate(row.deadline, 'Not specified')}</td>
|
||||
<td style={{ ...TD_BASE, textAlign: 'center' }}><StatusBadge status={row.status} /></td>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>{row.serviceType}</td>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>{fmtShortDate(row.deadline, 'Not specified')}</td>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, textAlign: 'center' }}><StatusBadge status={row.status} /></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
@@ -439,7 +436,7 @@ export default function ProjectDetailPage() {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{(() => {
|
||||
const subs = members.filter(m => m.profile?.role === 'external');
|
||||
if (subs.length === 0) return <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 100, color: 'var(--text-muted)', fontSize: 13 }}>No subcontractors assigned.</div>;
|
||||
if (subs.length === 0) return <div className="card-empty-center">No subcontractors</div>;
|
||||
const sorted = [...subs].sort((a, b) => {
|
||||
if (subSortKey === 'projects') {
|
||||
const av = subProjectCounts[a.profile_id] || 0;
|
||||
@@ -461,29 +458,25 @@ export default function ProjectDetailPage() {
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ ...TH_STYLE, padding: '0 0 12px' }} />
|
||||
<SortTh col="name" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={TH_STYLE}>Name</SortTh>
|
||||
<SortTh col="email" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={TH_STYLE}>Email</SortTh>
|
||||
<SortTh col="projects" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={{ ...TH_STYLE, textAlign: 'center' }}>Projects</SortTh>
|
||||
<th style={{ ...TH_STYLE, padding: '0 0 12px' }} />
|
||||
<th style={{ ...TASK_TABLE_TH_STYLE, padding: '0 0 12px' }} />
|
||||
<SortTh col="name" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={TASK_TABLE_TH_STYLE}>Name</SortTh>
|
||||
<SortTh col="email" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={TASK_TABLE_TH_STYLE}>Email</SortTh>
|
||||
<SortTh col="projects" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Projects</SortTh>
|
||||
<th style={{ ...TASK_TABLE_TH_STYLE, padding: '0 0 12px' }} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map(m => {
|
||||
const initials = (m.profile?.name || '?').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
|
||||
const projCount = subProjectCounts[m.profile_id] || 0;
|
||||
return (
|
||||
<tr key={m.id}>
|
||||
<td style={{ ...TD_BASE }}>
|
||||
{m.profile?.avatar_url
|
||||
? <div style={{ width: 26, height: 26, borderRadius: '50%', overflow: 'hidden' }}><img src={m.profile.avatar_url} alt={m.profile.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /></div>
|
||||
: <div style={{ width: 26, height: 26, borderRadius: '50%', background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}><span style={{ fontSize: 10, fontWeight: 600, color: '#000', lineHeight: 1 }}>{initials}</span></div>
|
||||
}
|
||||
<td style={{ ...TASK_TABLE_TD_BASE }}>
|
||||
<ProfileAvatar profile={m.profile} name={m.profile?.name} size={26} fontSize={10} />
|
||||
</td>
|
||||
<td style={{ ...TD_BASE, fontSize: 13, color: 'var(--text-primary)' }}>{m.profile?.name || '—'}</td>
|
||||
<td style={{ ...TD_BASE, fontSize: 12, color: 'var(--text-muted)' }}>{m.profile?.email || '—'}</td>
|
||||
<td style={{ ...TD_BASE, fontSize: 13, color: 'var(--text-primary)', textAlign: 'center', fontVariantNumeric: 'tabular-nums' }}>{projCount}</td>
|
||||
<td style={{ ...TD_BASE }} />
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)' }}>{m.profile?.name || '—'}</td>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-muted)' }}>{m.profile?.email || '—'}</td>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)', textAlign: 'center', fontVariantNumeric: 'tabular-nums' }}>{projCount}</td>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE }} />
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
@@ -502,17 +495,13 @@ export default function ProjectDetailPage() {
|
||||
<div className="card" style={CARD}>
|
||||
<div style={LABEL}>Main Contact</div>
|
||||
{companyClients.length === 0
|
||||
? <div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No contacts found.</div>
|
||||
? <div className="card-empty-center">No contacts</div>
|
||||
: (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{companyClients.map(person => {
|
||||
const initials = (person.name || '?').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
|
||||
return (
|
||||
<div key={person.id} style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
{person.avatar_url
|
||||
? <div style={{ width: 32, height: 32, borderRadius: '50%', overflow: 'hidden', flexShrink: 0 }}><img src={person.avatar_url} alt={person.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /></div>
|
||||
: <div style={{ width: 32, height: 32, borderRadius: '50%', background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}><span style={{ fontSize: 12, fontWeight: 600, color: '#000', lineHeight: 1 }}>{initials}</span></div>
|
||||
}
|
||||
<ProfileAvatar profile={person} name={person.name} size={32} fontSize={12} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)' }}>{person.name}</div>
|
||||
{person.email && <div style={{ fontSize: 11, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{person.email}</div>}
|
||||
@@ -530,7 +519,7 @@ export default function ProjectDetailPage() {
|
||||
{(() => {
|
||||
const SHOW = { task_started: { label: 'started', color: '#60a5fa', bg: 'rgba(96,165,250,0.15)', path: 'M6 4l14 8-14 8V4z' }, task_on_hold: { label: 'placed on hold', color: '#ef4444', bg: 'rgba(239,68,68,0.15)', path: 'M6 4h4v16H6zM14 4h4v16h-4z' }, task_approved: { label: 'approved', color: '#4ade80', bg: 'rgba(74,222,128,0.15)', path: 'M4 13l5 5L20 7' } };
|
||||
const filtered = activityLog;
|
||||
if (filtered.length === 0) return <div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No activity yet.</div>;
|
||||
if (filtered.length === 0) return <div className="card-empty-center">No activity</div>;
|
||||
return (
|
||||
<div className="scrollbar-thin-theme" style={{ flex: 1, minHeight: 0, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{filtered.map(e => {
|
||||
@@ -561,51 +550,10 @@ export default function ProjectDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Task modal — team only */}
|
||||
{showAddJob && isTeam && (
|
||||
<div style={popupOverlayStyle} onClick={() => { setShowAddJob(false); setJobForm(emptyJobForm()); }}>
|
||||
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(520px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }}>Add Task — {project.name}</div>
|
||||
<form onSubmit={handleAddJob} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div>
|
||||
<div style={MODAL_LBL}>Task Title *</div>
|
||||
<input autoFocus required value={jobForm.title} onChange={e => setJobForm(f => ({ ...f, title: e.target.value }))} placeholder="e.g. Logo Design" style={MODAL_IN} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={MODAL_LBL}>Service Type *</div>
|
||||
<select required value={jobForm.serviceType} onChange={e => setJobForm(f => ({ ...f, serviceType: e.target.value }))} style={MODAL_IN}>
|
||||
<option value="">Select a service…</option>
|
||||
{serviceTypes.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<div style={MODAL_LBL}>Deadline</div>
|
||||
<input type="date" value={jobForm.deadline} onChange={e => setJobForm(f => ({ ...f, deadline: e.target.value }))} style={MODAL_IN} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={MODAL_LBL}>Requested By *</div>
|
||||
<select required value={jobForm.requestedBy} onChange={e => setJobForm(f => ({ ...f, requestedBy: e.target.value }))} style={MODAL_IN}>
|
||||
<option value="">Select requester…</option>
|
||||
{requesterOptions.map(u => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<div style={MODAL_LBL}>Notes</div>
|
||||
<textarea value={jobForm.description} onChange={e => setJobForm(f => ({ ...f, description: e.target.value }))} placeholder="Any details…" rows={3} style={{ ...MODAL_IN, resize: 'vertical' }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', paddingTop: 4 }}>
|
||||
<button type="submit" className="btn btn-outline" disabled={savingJob}>{savingJob ? 'Saving…' : 'Save'}</button>
|
||||
<button type="button" className="btn btn-outline" onClick={() => { setShowAddJob(false); setJobForm(emptyJobForm()); }}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showClientTask && isClient && (
|
||||
<div style={popupOverlayStyle} onClick={() => { setShowClientTask(false); setClientTaskKey(k => k + 1); setClientTaskError(''); }}>
|
||||
<div style={{ ...popupSurfaceStyle, width: 'min(720px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto' }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 14 }}>New Task</div>
|
||||
<div style={TASK_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ ...TASK_MODAL_TITLE_STYLE, marginBottom: 14 }}>New Task</div>
|
||||
<RequestForm
|
||||
key={clientTaskKey}
|
||||
companies={company ? [company] : []}
|
||||
@@ -626,14 +574,14 @@ export default function ProjectDetailPage() {
|
||||
|
||||
{addingMember && isTeam && (
|
||||
<div style={popupOverlayStyle} onClick={() => { setAddingMember(false); setSelectedExts(new Set()); }}>
|
||||
<div style={{ ...popupSurfaceStyle, width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', display: 'flex', flexDirection: 'column', gap: 16, padding: 0 }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ ...TASK_MODAL_SHELL_STYLE, width: 'min(480px, 96vw)', display: 'flex', flexDirection: 'column', gap: 16, padding: 0 }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ padding: '18px 21px 0' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 2 }}>Manage Subcontractors</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>{project.name}</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '0 21px' }}>
|
||||
{externalProfiles.length === 0
|
||||
? <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 80, fontSize: 13, color: 'var(--text-muted)' }}>No subcontractors found.</div>
|
||||
? <div className="card-empty-center">No subcontractors</div>
|
||||
: <table style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
|
||||
<colgroup>
|
||||
<col style={{ width: '5%' }} />
|
||||
@@ -643,10 +591,10 @@ export default function ProjectDetailPage() {
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ ...TH_STYLE, padding: '0 0 12px' }} />
|
||||
<SortTh col="name" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={TH_STYLE}>Name</SortTh>
|
||||
<SortTh col="email" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={TH_STYLE}>Email</SortTh>
|
||||
<th style={{ ...TH_STYLE, padding: '0 0 12px', textAlign: 'center' }}>Assigned</th>
|
||||
<th style={{ ...TASK_TABLE_TH_STYLE, padding: '0 0 12px' }} />
|
||||
<SortTh col="name" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={TASK_TABLE_TH_STYLE}>Name</SortTh>
|
||||
<SortTh col="email" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={TASK_TABLE_TH_STYLE}>Email</SortTh>
|
||||
<th style={{ ...TASK_TABLE_TH_STYLE, padding: '0 0 12px', textAlign: 'center' }}>Assigned</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -656,16 +604,10 @@ export default function ProjectDetailPage() {
|
||||
return subSortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
|
||||
}).map(p => {
|
||||
const checked = selectedExts.has(p.id);
|
||||
const initials = (p.name || '?').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
|
||||
return (
|
||||
<tr key={p.id} onClick={() => setSelectedExts(prev => { const s = new Set(prev); s.has(p.id) ? s.delete(p.id) : s.add(p.id); return s; })} style={{ cursor: 'pointer' }}>
|
||||
<td style={{ padding: '5px 0', border: 'none', background: 'transparent' }}>
|
||||
{p.avatar_url
|
||||
? <div style={{ width: 26, height: 26, borderRadius: '50%', overflow: 'hidden' }}><img src={p.avatar_url} alt={p.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /></div>
|
||||
: <div style={{ width: 26, height: 26, borderRadius: '50%', background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<span style={{ fontSize: 10, fontWeight: 600, color: '#000', lineHeight: 1 }}>{initials}</span>
|
||||
</div>
|
||||
}
|
||||
<ProfileAvatar profile={p} name={p.name} size={26} fontSize={10} />
|
||||
</td>
|
||||
<td style={{ padding: '5px 5px 5px 12px', border: 'none', background: 'transparent', fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</td>
|
||||
<td style={{ padding: '5px', border: 'none', background: 'transparent', fontSize: 12, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.email || '—'}</td>
|
||||
@@ -681,7 +623,7 @@ export default function ProjectDetailPage() {
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
<div style={{ padding: '0 21px 18px', display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<div className="modal-action-row" style={{ padding: '0 21px 18px' }}>
|
||||
<button className="btn btn-outline" disabled={savingMembers} onClick={handleAddMember}>{savingMembers ? 'Saving…' : 'Save'}</button>
|
||||
<button className="btn btn-outline" onClick={() => { setAddingMember(false); setSelectedExts(new Set()); }}>Cancel</button>
|
||||
</div>
|
||||
|
||||
+374
-163
@@ -1,19 +1,20 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import Layout from '../components/Layout';
|
||||
import PageLoader from '../components/PageLoader';
|
||||
import FileBrowser from '../components/FileBrowser';
|
||||
import ProfileAvatar from '../components/ProfileAvatar';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { logActivity } from '../lib/activityLog';
|
||||
import JSZip from 'jszip';
|
||||
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
|
||||
import { sendEmail } from '../lib/email';
|
||||
import { uploadFilesToRequestInfo, safeName } from '../lib/filebrowserFolders';
|
||||
import { useRefetchOnFocus } from '../hooks/useRefetchOnFocus';
|
||||
import { useRealtimeSubscription } from '../hooks/useRealtimeSubscription';
|
||||
import { useLiveRefresh } from '../hooks/useLiveRefresh';
|
||||
import FileAttachment from '../components/FileAttachment';
|
||||
import { sendTaskStatusUpdate } from '../lib/taskNotifications';
|
||||
import { encodeRejectedNote, parseRejectedNote, isReviewShadowSubmission, REVIEW_SHADOW_PREFIX, getVisibleTaskSubmissions, getTaskDerivedState } from '../lib/taskVersions';
|
||||
import { getCurrentVersionForTask } from '../lib/taskDeadlines';
|
||||
import { getSubmissionDisplayName, mergeSubmissionDisplayNames } from '../lib/submissionDisplay';
|
||||
|
||||
const ACTION_LABEL = {
|
||||
task_created: 'created this task', task_started: 'started this task', task_on_hold: 'placed task on hold',
|
||||
@@ -63,8 +64,24 @@ const CARD = { padding: '18px 21px', borderRadius: 8 };
|
||||
const LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 14 };
|
||||
const META_LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 4 };
|
||||
const CARD_META_LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 2 };
|
||||
const MODAL_ACTION_ROW = { display: 'flex', gap: 8, justifyContent: 'flex-end', paddingTop: 4, flexWrap: 'nowrap' };
|
||||
const MODAL_ACTION_BUTTON = { width: 132, justifyContent: 'center', flex: '0 0 132px' };
|
||||
|
||||
const TABS = ['Overview', 'Revisions', 'Submissions', 'Comments', 'Folder'];
|
||||
const TABS = ['Overview', 'Revisions', 'Submissions', 'Comments'];
|
||||
function RejectedNoteBlock({ note }) {
|
||||
if (!note?.body) return null;
|
||||
const d = note.created_at ? new Date(note.created_at) : null;
|
||||
const dateStr = d ? d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—';
|
||||
return (
|
||||
<div style={{ padding: '12px 14px', borderRadius: 8, border: '1px solid color-mix(in srgb, var(--danger) 30%, var(--border))', background: 'color-mix(in srgb, var(--danger) 8%, var(--card-bg-2))' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, marginBottom: 6 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--danger)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Rejected Notes</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', flexShrink: 0 }}>{dateStr}</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{note.body}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineCard({ task, activityLog, currentSubmission }) {
|
||||
const status = task?.status;
|
||||
@@ -135,19 +152,7 @@ function TimelineCard({ task, activityLog, currentSubmission }) {
|
||||
}
|
||||
|
||||
function AssigneePortrait({ name, avatarUrl }) {
|
||||
if (avatarUrl) {
|
||||
return (
|
||||
<div style={{ width: 32, height: 32, borderRadius: '50%', overflow: 'hidden', flexShrink: 0 }}>
|
||||
<img src={avatarUrl} alt={name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const initials = (name || '?').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
|
||||
return (
|
||||
<div style={{ width: 32, height: 32, borderRadius: '50%', background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: '#000', lineHeight: 1 }}>{initials}</span>
|
||||
</div>
|
||||
);
|
||||
return <ProfileAvatar name={name} avatarUrl={avatarUrl} size={32} fontSize={12} />;
|
||||
}
|
||||
|
||||
const fmtSize = (bytes) => {
|
||||
@@ -194,13 +199,55 @@ function SubmissionFiles({ files, downloading, onDownloadAll }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SubmissionSigns({ signs = [], signCount = null }) {
|
||||
const normalizedCount = Number(signCount || 0);
|
||||
if ((!signs || signs.length === 0) && normalizedCount <= 0) return null;
|
||||
return (
|
||||
<div>
|
||||
<div style={META_LABEL}>Signs</div>
|
||||
{signs && signs.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{signs
|
||||
.slice()
|
||||
.sort((a, b) => Number(a.sign_number || 0) - Number(b.sign_number || 0))
|
||||
.map((sign) => (
|
||||
<div key={sign.id || `${sign.sign_number}-${sign.sign_name}`} style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)', lineHeight: 1.5 }}>
|
||||
<span style={{ color: 'var(--text-secondary)', marginRight: 8 }}>Sign {sign.sign_number || '—'}</span>
|
||||
<span>{sign.sign_name || 'Unnamed sign'}</span>
|
||||
</div>
|
||||
{sign.sign_family ? (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.4 }}>
|
||||
{sign.sign_family}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)', lineHeight: 1.5 }}>
|
||||
N/A
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeSubmissionSigns(submission) {
|
||||
return (Array.isArray(submission?.signs) ? submission.signs : [])
|
||||
.map((sign, index) => ({
|
||||
...sign,
|
||||
sign_number: Number(sign?.sign_number || index + 1),
|
||||
sign_name: String(sign?.sign_name || '').trim(),
|
||||
sign_family: String(sign?.sign_family || '').trim(),
|
||||
}))
|
||||
.filter(sign => sign.sign_name || sign.sign_family || Number.isFinite(sign.sign_number));
|
||||
}
|
||||
|
||||
export default function TaskDetail() {
|
||||
const { id } = useParams();
|
||||
const { currentUser } = useAuth();
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const refresh = useCallback(() => setRefreshKey(k => k + 1), []);
|
||||
useRefetchOnFocus(refresh);
|
||||
useRealtimeSubscription(['tasks', 'submissions', 'activity_log', 'task_comments'], refresh);
|
||||
const { refreshKey } = useLiveRefresh(['tasks', 'submissions', 'activity_log', 'task_comments']);
|
||||
const [task, setTask] = useState(null);
|
||||
const [submissions, setSubmissions] = useState([]);
|
||||
const [activityLog, setActivityLog] = useState([]);
|
||||
@@ -213,25 +260,55 @@ export default function TaskDetail() {
|
||||
const [revisionFiles, setRevisionFiles] = useState([]);
|
||||
const [revisionSaving, setRevisionSaving] = useState(false);
|
||||
const revisionFileRef = useRef(null);
|
||||
const [rejectModal, setRejectModal] = useState(false);
|
||||
const [rejectNote, setRejectNote] = useState('');
|
||||
const [rejectSaving, setRejectSaving] = useState(false);
|
||||
const [amendModal, setAmendModal] = useState(false);
|
||||
const [amendForm, setAmendForm] = useState({ description: '' });
|
||||
const [amendFiles, setAmendFiles] = useState([]);
|
||||
const [amendSaving, setAmendSaving] = useState(false);
|
||||
const [amendDragging, setAmendDragging] = useState(false);
|
||||
const amendDragCounter = useRef(0);
|
||||
const amendFileRef = useRef(null);
|
||||
const [reviewModal, setReviewModal] = useState(false);
|
||||
const [reviewFiles, setReviewFiles] = useState([]);
|
||||
const [reviewNotes, setReviewNotes] = useState('');
|
||||
const [reviewSaving, setReviewSaving] = useState(false);
|
||||
const [reviewDragging, setReviewDragging] = useState(false);
|
||||
const reviewDragCounter = useRef(0);
|
||||
const reviewFileRef = useRef(null);
|
||||
const [comments, setComments] = useState([]);
|
||||
const [commentBody, setCommentBody] = useState('');
|
||||
const [commentSaving, setCommentSaving] = useState(false);
|
||||
const [deliveries, setDeliveries] = useState([]);
|
||||
const [dlProgress, setDlProgress] = useState({ active: false, label: '', percent: 0 });
|
||||
const canonicalTaskServiceType = getTaskDerivedState(task, submissions, deliveries).serviceType;
|
||||
|
||||
const fetchTaskSubmissions = async (taskId) => {
|
||||
const { data: subRows } = await supabase
|
||||
.from('submissions')
|
||||
.select('id, type, version_number, request_key, submitted_by, submitted_by_name, submitted_at, service_type, deadline, description, is_hot, sign_family, sign_count, signs, files:submission_files(id, name, storage_path, size)')
|
||||
.eq('task_id', taskId)
|
||||
.order('submitted_at', { ascending: true });
|
||||
|
||||
const submitterIds = [...new Set((subRows || []).map((submission) => submission.submitted_by).filter(Boolean))];
|
||||
if (submitterIds.length === 0) {
|
||||
return (subRows || []).map(submissionRow => ({ ...submissionRow, signs: normalizeSubmissionSigns(submissionRow) }));
|
||||
}
|
||||
|
||||
const { data: submitterProfiles } = await supabase
|
||||
.from('profiles')
|
||||
.select('id, name')
|
||||
.in('id', submitterIds);
|
||||
|
||||
return mergeSubmissionDisplayNames(
|
||||
(subRows || []).map(submissionRow => ({ ...submissionRow, signs: normalizeSubmissionSigns(submissionRow) })),
|
||||
submitterProfiles || []
|
||||
);
|
||||
};
|
||||
|
||||
const reloadTaskRow = async () => {
|
||||
const { data } = await supabase
|
||||
.from('tasks')
|
||||
.select('*, project:projects(id, name, company:companies(id, name)), assignee:profiles!assigned_to(id, name, avatar_url)')
|
||||
.eq('id', id)
|
||||
.single();
|
||||
if (data) setTask(data);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
@@ -240,11 +317,7 @@ export default function TaskDetail() {
|
||||
.select('*, project:projects(id, name, company:companies(id, name)), assignee:profiles!assigned_to(id, name, avatar_url)')
|
||||
.eq('id', id)
|
||||
.single(),
|
||||
supabase
|
||||
.from('submissions')
|
||||
.select('id, type, version_number, request_key, submitted_by, submitted_by_name, submitted_at, service_type, deadline, description, is_hot, sign_family, sign_count, files:submission_files(id, name, storage_path, size)')
|
||||
.eq('task_id', id)
|
||||
.order('submitted_at', { ascending: true }),
|
||||
fetchTaskSubmissions(id),
|
||||
supabase
|
||||
.from('activity_log')
|
||||
.select('id, created_at, actor_id, actor_name, action, task_id, task_title')
|
||||
@@ -256,7 +329,7 @@ export default function TaskDetail() {
|
||||
.select('id, author_id, author_name, body, created_at')
|
||||
.eq('task_id', id)
|
||||
.order('created_at', { ascending: true }),
|
||||
]).then(async ([{ data: taskData }, { data: subRows }, { data: actData }, { data: commentData }]) => {
|
||||
]).then(async ([{ data: taskData }, subRows, { data: actData }, { data: commentData }]) => {
|
||||
setTask(taskData);
|
||||
setSubmissions(subRows || []);
|
||||
setActivityLog(actData || []);
|
||||
@@ -277,76 +350,225 @@ export default function TaskDetail() {
|
||||
if (loading) return <Layout><PageLoader /></Layout>;
|
||||
if (!task) return <Layout><p style={{ padding: 24 }}>Task not found.</p></Layout>;
|
||||
|
||||
const submission = submissions.find(s => s.type === 'initial') || submissions[0] || null;
|
||||
const amendments = submissions.filter(s => s.type === 'amendment');
|
||||
const revisions = submissions.filter(s => s.type === 'revision').sort((a, b) => a.version_number - b.version_number);
|
||||
const visibleSubmissions = getVisibleTaskSubmissions(submissions);
|
||||
const submission = visibleSubmissions.find(s => s.type === 'initial') || visibleSubmissions[0] || null;
|
||||
const rejectedNoteSubmissions = visibleSubmissions
|
||||
.filter(s => s.type === 'amendment' && parseRejectedNote(s.description))
|
||||
.map(s => ({ ...s, rejected: parseRejectedNote(s.description) }));
|
||||
const amendments = visibleSubmissions.filter(s => s.type === 'amendment' && !parseRejectedNote(s.description));
|
||||
const revisions = visibleSubmissions.filter(s => s.type === 'revision').sort((a, b) => a.version_number - b.version_number);
|
||||
const reviewSubmissions = deliveries;
|
||||
const commentRows = comments;
|
||||
const rejectedNoteByVersion = new Map(
|
||||
rejectedNoteSubmissions.map(note => [note.rejected.versionNumber, { ...note, body: note.rejected.body, created_at: note.submitted_at }])
|
||||
);
|
||||
const displayCurrentVersion = getCurrentVersionForTask(task, submissions);
|
||||
|
||||
const assignee = task.assignee;
|
||||
const assigneeName = assignee?.name || task.assigned_name || null;
|
||||
const assigneeAvatar = assignee?.avatar_url || null;
|
||||
const project = task.project;
|
||||
const company = project?.company;
|
||||
const requester = submission?.submitted_by_name || null;
|
||||
const requester = getSubmissionDisplayName(submission) || null;
|
||||
const role = currentUser?.role;
|
||||
const isClient = role === 'client';
|
||||
const isTeamOrSub = role === 'team' || role === 'external';
|
||||
const status = task.status;
|
||||
const notificationBase = {
|
||||
taskId: id,
|
||||
taskTitle: task?.title || '',
|
||||
projectId: project?.id || '',
|
||||
projectName: project?.name || '',
|
||||
companyId: company?.id || '',
|
||||
companyName: company?.name || '',
|
||||
assignedProfileId: task?.assigned_to || currentUser?.id || '',
|
||||
};
|
||||
|
||||
const actionBusy = statusSaving || reviewSaving || revisionSaving || rejectSaving || amendSaving;
|
||||
|
||||
const log = (action) => logActivity({ actorId: currentUser.id, actorName: currentUser.name, action, taskId: id, taskTitle: task?.title, projectId: task?.project?.id, projectName: task?.project?.name });
|
||||
|
||||
const updateStatus = async (newStatus, extra = {}, action = null) => {
|
||||
setStatusSaving(true);
|
||||
await supabase.from('tasks').update({ status: newStatus, ...extra }).eq('id', id);
|
||||
const prevTask = task;
|
||||
setTask(t => ({ ...t, status: newStatus, ...extra }));
|
||||
if (action) {
|
||||
await log(action);
|
||||
const { data } = await supabase.from('activity_log').select('id, created_at, actor_id, actor_name, action').eq('task_id', id).order('created_at', { ascending: false }).limit(20);
|
||||
setActivityLog(data || []);
|
||||
try {
|
||||
const { error: taskUpdateError } = await supabase.from('tasks').update({ status: newStatus, ...extra }).eq('id', id);
|
||||
if (taskUpdateError) throw taskUpdateError;
|
||||
await reloadTaskRow();
|
||||
if (action) {
|
||||
await log(action);
|
||||
const { data } = await supabase.from('activity_log').select('id, created_at, actor_id, actor_name, action').eq('task_id', id).order('created_at', { ascending: false }).limit(20);
|
||||
setActivityLog(data || []);
|
||||
}
|
||||
} catch (err) {
|
||||
setTask(prevTask);
|
||||
console.error('Status update failed:', err);
|
||||
window.alert(err?.message || 'Failed to update status.');
|
||||
throw err;
|
||||
} finally {
|
||||
setStatusSaving(false);
|
||||
}
|
||||
setStatusSaving(false);
|
||||
};
|
||||
|
||||
const handleStart = () => updateStatus('in_progress', { assigned_to: currentUser.id, assigned_name: currentUser.name }, 'task_started');
|
||||
const handleOnHold = () => updateStatus('on_hold', {}, 'task_on_hold');
|
||||
const handleOnHold = async () => {
|
||||
await updateStatus('on_hold', {}, 'task_on_hold');
|
||||
sendTaskStatusUpdate({
|
||||
...notificationBase,
|
||||
statusLabel: 'On Hold',
|
||||
headline: 'Task On Hold',
|
||||
subject: `Task On Hold: ${task?.title || 'Task'}`,
|
||||
message: `${currentUser.name} placed ${task?.title || 'this task'} on hold${project?.name ? ` for ${project.name}` : ''}.`,
|
||||
includeTeam: true,
|
||||
includeAssigned: true,
|
||||
includeClient: true,
|
||||
}).catch(() => {});
|
||||
};
|
||||
const handleResume = () => updateStatus('in_progress', {}, 'task_resumed');
|
||||
const handleSendToReview = () => { setReviewFiles([]); setReviewNotes(''); setReviewModal(true); };
|
||||
const handleRemoveFromReview = () => updateStatus('in_progress', {}, 'task_resumed');
|
||||
const handleConfirmReview = async () => {
|
||||
setReviewSaving(true);
|
||||
try {
|
||||
const briefId = submissions.find(s => s.type === 'initial')?.id || submissions[0]?.id;
|
||||
const { data: newDel } = await supabase.from('deliveries').insert({ submission_id: briefId, sent_by: currentUser.name, message: reviewNotes.trim() || '', version_number: task.current_version || 0 }).select().single();
|
||||
const targetVersion = task.current_version || 0;
|
||||
const versionSubmissions = submissions.filter(s => (s.version_number || 0) === targetVersion && s.type !== 'amendment');
|
||||
const usedSubmissionIds = new Set(
|
||||
deliveries
|
||||
.filter(d => (d.version_number || 0) === targetVersion)
|
||||
.filter(d => versionSubmissions.some(s => s.id === d.submission_id))
|
||||
.map(d => d.submission_id)
|
||||
);
|
||||
let targetSubmission = versionSubmissions.find(s => !usedSubmissionIds.has(s.id)) || null;
|
||||
if (!targetSubmission) {
|
||||
const { data: createdSubmission, error: createdSubmissionError } = await supabase
|
||||
.from('submissions')
|
||||
.insert({
|
||||
task_id: id,
|
||||
version_number: targetVersion,
|
||||
type: targetVersion === 0 ? 'initial' : 'revision',
|
||||
revision_type: targetVersion > 0 ? 'client_revision' : null,
|
||||
service_type: canonicalTaskServiceType !== '—' ? canonicalTaskServiceType : null,
|
||||
description: `${REVIEW_SHADOW_PREFIX}${targetVersion}`,
|
||||
submitted_by: currentUser.id,
|
||||
submitted_by_name: currentUser.name,
|
||||
})
|
||||
.select('id, type, version_number, request_key, submitted_by, submitted_by_name, submitted_at, service_type, deadline, description, is_hot, sign_family, sign_count, signs')
|
||||
.single();
|
||||
if (createdSubmissionError) throw createdSubmissionError;
|
||||
targetSubmission = createdSubmission || null;
|
||||
}
|
||||
if (!targetSubmission?.id) throw new Error('No submission found for current version.');
|
||||
const { data: newDel, error: deliveryError } = await supabase
|
||||
.from('deliveries')
|
||||
.insert({ submission_id: targetSubmission.id, sent_by: currentUser.name, message: reviewNotes.trim() || '', version_number: targetVersion })
|
||||
.select()
|
||||
.single();
|
||||
if (deliveryError) throw deliveryError;
|
||||
if (!newDel?.id) throw new Error('Failed to create review submission.');
|
||||
if (newDel && reviewFiles.length > 0) {
|
||||
const uploaded = [];
|
||||
for (const file of reviewFiles) {
|
||||
const path = `${id}/${Date.now()}_${file.name}`;
|
||||
const { data: up } = await supabase.storage.from('deliveries').upload(path, file);
|
||||
const { data: up, error: uploadError } = await supabase.storage.from('deliveries').upload(path, file);
|
||||
if (uploadError) throw uploadError;
|
||||
if (up) uploaded.push({ name: file.name, storage_path: path, size: file.size, delivery_id: newDel.id });
|
||||
}
|
||||
if (uploaded.length > 0) await supabase.from('delivery_files').insert(uploaded);
|
||||
if (uploaded.length > 0) {
|
||||
const { error: deliveryFilesError } = await supabase.from('delivery_files').insert(uploaded);
|
||||
if (deliveryFilesError) throw deliveryFilesError;
|
||||
}
|
||||
}
|
||||
const subIds = submissions.map(s => s.id);
|
||||
const refreshedSubRows = await fetchTaskSubmissions(id);
|
||||
const allSubRows = refreshedSubRows || [];
|
||||
setSubmissions(allSubRows);
|
||||
const subIds = allSubRows.map(s => s.id);
|
||||
const { data: delRows } = await supabase.from('deliveries').select('*, files:delivery_files(id, name, storage_path, size)').in('submission_id', subIds).order('sent_at', { ascending: false });
|
||||
setDeliveries(delRows || []);
|
||||
await updateStatus('client_review', {}, 'task_submitted');
|
||||
sendTaskStatusUpdate({
|
||||
...notificationBase,
|
||||
statusLabel: 'In Review',
|
||||
headline: 'Task Ready For Review',
|
||||
subject: `In Review: ${task?.title || 'Task'}`,
|
||||
message: `${currentUser.name} placed ${task?.title || 'this task'} in review${project?.name ? ` for ${project.name}` : ''}.${reviewNotes.trim() ? ` Notes: ${reviewNotes.trim()}` : ''}`,
|
||||
includeTeam: true,
|
||||
includeAssigned: true,
|
||||
includeClient: true,
|
||||
}).catch(() => {});
|
||||
setReviewModal(false);
|
||||
setReviewFiles([]);
|
||||
setReviewNotes('');
|
||||
} catch (err) {
|
||||
console.error('Place in review failed:', err);
|
||||
window.alert(err?.message || 'Failed to place task in review.');
|
||||
} finally {
|
||||
setReviewSaving(false);
|
||||
}
|
||||
};
|
||||
const handleClientApprove = () => updateStatus('client_approved', { completed_at: new Date().toISOString() }, 'task_approved');
|
||||
const handleClientReject = () => updateStatus('not_started', { current_version: (task.current_version || 0) + 1, assigned_to: null, assigned_name: null }, 'revision_requested');
|
||||
const handleClientApprove = async () => {
|
||||
await updateStatus('client_approved', { completed_at: new Date().toISOString() }, 'task_approved');
|
||||
sendTaskStatusUpdate({
|
||||
...notificationBase,
|
||||
statusLabel: 'Approved',
|
||||
headline: 'Task Approved',
|
||||
subject: `Approved: ${task?.title || 'Task'}`,
|
||||
message: `${currentUser.name} approved ${task?.title || 'this task'}${project?.name ? ` for ${project.name}` : ''}.`,
|
||||
includeTeam: true,
|
||||
includeAssigned: true,
|
||||
includeClient: true,
|
||||
}).catch(() => {});
|
||||
};
|
||||
const handleClientReject = () => { setRejectNote(''); setRejectModal(true); };
|
||||
const handleSubmitReject = async () => {
|
||||
if (!rejectNote.trim()) return;
|
||||
setRejectSaving(true);
|
||||
try {
|
||||
const rejectedVersion = task.current_version || 0;
|
||||
const { data: rejectedNoteSubmission } = await supabase
|
||||
.from('submissions')
|
||||
.insert({
|
||||
task_id: id,
|
||||
version_number: rejectedVersion,
|
||||
type: 'amendment',
|
||||
service_type: canonicalTaskServiceType !== '—' ? canonicalTaskServiceType : null,
|
||||
description: encodeRejectedNote(rejectedVersion, rejectNote.trim()),
|
||||
submitted_by: currentUser.id,
|
||||
submitted_by_name: currentUser.name,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (rejectedNoteSubmission) setSubmissions(prev => [...prev, rejectedNoteSubmission]);
|
||||
await updateStatus('in_progress', {}, 'revision_requested');
|
||||
sendTaskStatusUpdate({
|
||||
...notificationBase,
|
||||
statusLabel: 'Rejected',
|
||||
headline: 'Task Rejected',
|
||||
subject: `Rejected: ${task?.title || 'Task'}`,
|
||||
message: `${currentUser.name} rejected ${task?.title || 'this task'}${project?.name ? ` for ${project.name}` : ''}. Notes: ${rejectNote.trim()}`,
|
||||
includeTeam: true,
|
||||
includeAssigned: true,
|
||||
includeClient: true,
|
||||
}).catch(() => {});
|
||||
setRejectModal(false);
|
||||
setRejectNote('');
|
||||
} finally {
|
||||
setRejectSaving(false);
|
||||
}
|
||||
};
|
||||
const handleRelease = () => updateStatus('not_started', { assigned_to: null, assigned_name: null }, 'task_released');
|
||||
|
||||
const handleSubmitRevision = async () => {
|
||||
setRevisionSaving(true);
|
||||
const baseline = Math.max(task.current_version || 0, ...submissions.map(s => s.version_number || 0));
|
||||
const newVersion = baseline + 1;
|
||||
await supabase.from('tasks').update({ status: 'not_started', current_version: newVersion, assigned_to: null, assigned_name: null }).eq('id', id);
|
||||
const { data: newSub } = await supabase.from('submissions').insert({ task_id: id, version_number: newVersion, type: 'revision', revision_type: 'client_revision', service_type: task.title, deadline: revisionForm.deadline || null, description: revisionForm.description, submitted_by: currentUser.id, submitted_by_name: currentUser.name }).select().single();
|
||||
await supabase.from('tasks').update({
|
||||
status: 'not_started',
|
||||
current_version: newVersion,
|
||||
assigned_to: task?.assigned_to || null,
|
||||
assigned_name: task?.assigned_name || task?.assignee?.name || null,
|
||||
}).eq('id', id);
|
||||
const { data: newSub } = await supabase.from('submissions').insert({ task_id: id, version_number: newVersion, type: 'revision', revision_type: 'client_revision', service_type: canonicalTaskServiceType !== '—' ? canonicalTaskServiceType : null, deadline: revisionForm.deadline || null, description: revisionForm.description, submitted_by: currentUser.id, submitted_by_name: currentUser.name }).select().single();
|
||||
if (newSub && revisionFiles.length > 0) {
|
||||
const uploadedFiles = [];
|
||||
for (const file of revisionFiles) {
|
||||
@@ -355,18 +577,27 @@ export default function TaskDetail() {
|
||||
if (uploaded) uploadedFiles.push({ name: file.name, storage_path: path, size: file.size });
|
||||
}
|
||||
if (uploadedFiles.length > 0) await supabase.from('submission_files').insert(uploadedFiles.map(f => ({ ...f, submission_id: newSub.id })));
|
||||
if (project?.name && task?.title) uploadFilesToRequestInfo(revisionFiles, company?.name, project.name, task.title, newVersion).catch(() => {});
|
||||
}
|
||||
await logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'revision_requested', taskId: id, taskTitle: task?.title, projectId: project?.id, projectName: project?.name });
|
||||
sendEmail('revision_submitted', ['hello@fourgebranding.com'], { clientName: currentUser.name, serviceType: task.title, projectName: project?.name, version: `R${String(newVersion).padStart(2, '0')}`, deadline: revisionForm.deadline, description: revisionForm.description, taskId: id }).catch(() => {});
|
||||
const [{ data: taskData }, { data: subRows }, { data: actData }] = await Promise.all([
|
||||
const [{ data: taskData }, subRows, { data: actData }] = await Promise.all([
|
||||
supabase.from('tasks').select('*, project:projects(id, name, company:companies(id, name)), assignee:profiles!assigned_to(id, name, avatar_url)').eq('id', id).single(),
|
||||
supabase.from('submissions').select('id, type, version_number, submitted_by, submitted_by_name, submitted_at, service_type, deadline, description, is_hot, sign_family, sign_count, files:submission_files(id, name, storage_path, size)').eq('task_id', id).order('submitted_at', { ascending: true }),
|
||||
fetchTaskSubmissions(id),
|
||||
supabase.from('activity_log').select('id, created_at, actor_id, actor_name, action, task_id, task_title').eq('task_id', id).order('created_at', { ascending: false }).limit(20),
|
||||
]);
|
||||
setTask(taskData);
|
||||
setSubmissions(subRows || []);
|
||||
setActivityLog(actData || []);
|
||||
sendTaskStatusUpdate({
|
||||
...notificationBase,
|
||||
assignedProfileId: task?.assigned_to || '',
|
||||
statusLabel: `Revision Request ${`R${String(newVersion).padStart(2, '0')}`}`,
|
||||
headline: 'Revision Requested',
|
||||
subject: `Revision Request: ${task?.title || 'Task'} ${`R${String(newVersion).padStart(2, '0')}`}`,
|
||||
message: `${currentUser.name} requested a revision for ${task?.title || 'this task'}${project?.name ? ` on ${project.name}` : ''}. ${revisionForm.description}`,
|
||||
includeTeam: true,
|
||||
includeAssigned: true,
|
||||
includeClient: true,
|
||||
}).catch(() => {});
|
||||
setRevisionModal(false);
|
||||
setRevisionForm({ description: '', deadline: '' });
|
||||
setRevisionFiles([]);
|
||||
@@ -378,7 +609,7 @@ export default function TaskDetail() {
|
||||
setAmendSaving(true);
|
||||
setAmendModal(false);
|
||||
const baseline = Math.max(task.current_version || 0, ...submissions.map(s => s.version_number || 0));
|
||||
const { data: newSub } = await supabase.from('submissions').insert({ task_id: id, version_number: baseline, type: 'amendment', service_type: task.title, description: amendForm.description, submitted_by: currentUser.id, submitted_by_name: currentUser.name }).select().single();
|
||||
const { data: newSub } = await supabase.from('submissions').insert({ task_id: id, version_number: baseline, type: 'amendment', service_type: canonicalTaskServiceType !== '—' ? canonicalTaskServiceType : null, description: amendForm.description, submitted_by: currentUser.id, submitted_by_name: currentUser.name }).select().single();
|
||||
if (newSub && amendFiles.length > 0) {
|
||||
setDlProgress({ active: true, label: 'Uploading files…', percent: 0 });
|
||||
const uploaded = [];
|
||||
@@ -390,11 +621,20 @@ export default function TaskDetail() {
|
||||
if (up) uploaded.push({ name: file.name, storage_path: path, size: file.size });
|
||||
}
|
||||
if (uploaded.length > 0) await supabase.from('submission_files').insert(uploaded.map(f => ({ ...f, submission_id: newSub.id })));
|
||||
if (project?.name && task?.title) uploadFilesToRequestInfo(amendFiles, company?.name, project.name, task.title, baseline).catch(() => {});
|
||||
setDlProgress({ active: false, label: '', percent: 0 });
|
||||
}
|
||||
const { data: subRows } = await supabase.from('submissions').select('id, type, version_number, submitted_by, submitted_by_name, submitted_at, service_type, deadline, description, is_hot, sign_family, sign_count, files:submission_files(id, name, storage_path, size)').eq('task_id', id).order('submitted_at', { ascending: true });
|
||||
const subRows = await fetchTaskSubmissions(id);
|
||||
setSubmissions(subRows || []);
|
||||
sendTaskStatusUpdate({
|
||||
...notificationBase,
|
||||
statusLabel: 'Request Amended',
|
||||
headline: 'Request Amended',
|
||||
subject: `Request Amended: ${task?.title || 'Task'}`,
|
||||
message: `${currentUser.name} amended ${task?.title || 'this task'}${project?.name ? ` for ${project.name}` : ''}. Notes: ${amendForm.description.trim()}`,
|
||||
includeTeam: true,
|
||||
includeAssigned: true,
|
||||
includeClient: true,
|
||||
}).catch(() => {});
|
||||
setAmendForm({ description: '' });
|
||||
setAmendFiles([]);
|
||||
setAmendSaving(false);
|
||||
@@ -454,38 +694,38 @@ export default function TaskDetail() {
|
||||
<div style={{ fontSize: 28, fontWeight: 500, color: 'var(--text-primary)', lineHeight: 1.2 }}>{task.title}</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||
{isTeamOrSub && status === 'not_started' && (
|
||||
<button className="btn btn-outline" disabled={statusSaving} onClick={handleStart}>Start Task</button>
|
||||
<button className="btn btn-outline" disabled={actionBusy} onClick={handleStart}>Start Task</button>
|
||||
)}
|
||||
{isTeamOrSub && status === 'in_progress' && task.assigned_to === currentUser?.id && (
|
||||
<>
|
||||
<button className="btn btn-outline" disabled={statusSaving} onClick={handleRelease}>Release</button>
|
||||
<button className="btn btn-outline" disabled={statusSaving} onClick={handleOnHold}>Place on Hold</button>
|
||||
<button className="btn btn-primary" disabled={statusSaving} onClick={handleSendToReview}>Place in Review</button>
|
||||
<button className="btn btn-outline" disabled={actionBusy} onClick={handleRelease}>Release</button>
|
||||
<button className="btn btn-outline" disabled={actionBusy} onClick={handleOnHold}>Place on Hold</button>
|
||||
<button className="btn btn-primary" disabled={actionBusy} onClick={handleSendToReview}>Place in Review</button>
|
||||
</>
|
||||
)}
|
||||
{isTeamOrSub && status === 'client_review' && task.assigned_to === currentUser?.id && (
|
||||
<button className="btn btn-outline" disabled={statusSaving} onClick={handleRemoveFromReview}>Remove from Review</button>
|
||||
<button className="btn btn-outline" disabled={actionBusy} onClick={handleRemoveFromReview}>Remove from Review</button>
|
||||
)}
|
||||
{isTeamOrSub && status === 'on_hold' && task.assigned_to === currentUser?.id && (
|
||||
<>
|
||||
<button className="btn btn-outline" disabled={statusSaving} onClick={handleRelease}>Release</button>
|
||||
<button className="btn btn-outline" disabled={statusSaving} onClick={handleResume}>Resume</button>
|
||||
<button className="btn btn-outline" disabled={actionBusy} onClick={handleRelease}>Release</button>
|
||||
<button className="btn btn-outline" disabled={actionBusy} onClick={handleResume}>Resume</button>
|
||||
</>
|
||||
)}
|
||||
{isClient && status === 'client_review' && (
|
||||
<>
|
||||
<button className="btn btn-primary" disabled={statusSaving} onClick={handleClientApprove}>Approve</button>
|
||||
<button className="btn btn-danger" disabled={statusSaving} onClick={handleClientReject}>Reject</button>
|
||||
<button className="btn btn-primary" disabled={actionBusy} onClick={handleClientApprove}>Approve</button>
|
||||
<button className="btn btn-danger" disabled={actionBusy} onClick={handleClientReject}>Reject</button>
|
||||
</>
|
||||
)}
|
||||
{isClient && ['client_approved', 'invoiced', 'paid'].includes(status) && (
|
||||
<button className="btn btn-outline" onClick={() => setRevisionModal(true)}>Request Revision</button>
|
||||
<button className="btn btn-outline" disabled={actionBusy} onClick={() => setRevisionModal(true)}>Request Revision</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 20 }}>
|
||||
{submission?.service_type && (
|
||||
<span className="badge badge-status badge-client">{submission.service_type}</span>
|
||||
{canonicalTaskServiceType !== '—' && (
|
||||
<span className="badge badge-status badge-client">{canonicalTaskServiceType}</span>
|
||||
)}
|
||||
<StatusBadge status={task.status || 'not_started'} />
|
||||
</div>
|
||||
@@ -501,7 +741,7 @@ export default function TaskDetail() {
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><polyline points="17,1 21,5 17,9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7,23 3,19 7,15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
|
||||
<div>
|
||||
<div style={CARD_META_LABEL}>Revision</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>{(task.current_version ?? 0) === 0 ? 'New' : String(task.current_version).padStart(2, '0')}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>{displayCurrentVersion === 0 ? 'New' : String(displayCurrentVersion).padStart(2, '0')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
@@ -541,7 +781,7 @@ export default function TaskDetail() {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10 }}>
|
||||
{TABS.map(tab => {
|
||||
const count = tab === 'Revisions' ? revisions.length : tab === 'Submissions' ? reviewSubmissions.length : tab === 'Comments' ? comments.length : 0;
|
||||
const count = tab === 'Revisions' ? revisions.length : tab === 'Submissions' ? reviewSubmissions.length : tab === 'Comments' ? commentRows.length : 0;
|
||||
return (
|
||||
<button key={tab} onClick={() => setActiveTab(tab)} className={`section-tab-btn${activeTab === tab ? ' is-active' : ''}`}>
|
||||
{tab}{count > 0 && <span style={{ marginLeft: 5, fontSize: 12, fontWeight: 600, color: activeTab === tab ? 'var(--accent)' : 'var(--text-muted)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 10, padding: '3px 8px' }}>{count}</span>}
|
||||
@@ -549,8 +789,8 @@ export default function TaskDetail() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, overflowY: activeTab === 'Folder' ? 'hidden' : 'auto', padding: activeTab === 'Folder' ? 0 : CARD.padding, ...(activeTab === 'Folder' ? { display: 'flex', flexDirection: 'column' } : {}) }}>
|
||||
<div style={{ fontSize: 13, ...(activeTab === 'Folder' ? { flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 } : {}) }}>
|
||||
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
<div style={{ fontSize: 13 }}>
|
||||
{activeTab === 'Overview' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 32 }}>
|
||||
@@ -573,6 +813,11 @@ export default function TaskDetail() {
|
||||
<button className="btn btn-outline" onClick={() => setAmendModal(true)}>Amend Request</button>
|
||||
</div>
|
||||
)}
|
||||
{rejectedNoteByVersion.get(0) && (
|
||||
<div style={{ marginLeft: 'auto', flex: '0 0 min(320px, 36%)' }}>
|
||||
<RejectedNoteBlock note={rejectedNoteByVersion.get(0)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{submission?.description && (
|
||||
<div>
|
||||
@@ -586,6 +831,7 @@ export default function TaskDetail() {
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{submission.sign_family}</div>
|
||||
</div>
|
||||
)}
|
||||
<SubmissionSigns signs={submission?.signs} signCount={submission?.sign_count} />
|
||||
<SubmissionFiles files={submission?.files} downloading={downloading} onDownloadAll={downloadAllFiles} />
|
||||
{amendments.length > 0 && (
|
||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 20, display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
@@ -609,7 +855,7 @@ export default function TaskDetail() {
|
||||
)}
|
||||
{activeTab === 'Revisions' && (
|
||||
revisions.length === 0
|
||||
? <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 1, minHeight: 120, color: 'var(--text-muted)' }}>No revisions yet.</div>
|
||||
? <div className="card-empty-center">No revisions</div>
|
||||
: <div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
{[...revisions].reverse().map((rev, i, arr) => {
|
||||
const rNum = `R${String(rev.version_number).padStart(2, '0')}`;
|
||||
@@ -621,7 +867,7 @@ export default function TaskDetail() {
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 32 }}>
|
||||
<div>
|
||||
<div style={META_LABEL}>Requested By</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{rev.submitted_by_name || '—'}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{getSubmissionDisplayName(rev) || '—'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={META_LABEL}>Requested</div>
|
||||
@@ -640,6 +886,11 @@ export default function TaskDetail() {
|
||||
<button className="btn btn-outline" onClick={() => setAmendModal(true)}>Amend</button>
|
||||
</div>
|
||||
)}
|
||||
{rejectedNoteByVersion.get(rev.version_number) && (
|
||||
<div style={{ marginLeft: 'auto', flex: '0 0 min(320px, 36%)' }}>
|
||||
<RejectedNoteBlock note={rejectedNoteByVersion.get(rev.version_number)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{rev.description && (
|
||||
<div>
|
||||
@@ -653,7 +904,8 @@ export default function TaskDetail() {
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{rev.sign_family}</div>
|
||||
</div>
|
||||
)}
|
||||
{!rev.description && !rev.sign_family && (
|
||||
<SubmissionSigns signs={rev.signs} signCount={rev.sign_count} />
|
||||
{!rev.description && !rev.sign_family && !(rev.signs?.length > 0) && !(Number(rev.sign_count || 0) > 0) && (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No notes provided.</div>
|
||||
)}
|
||||
<SubmissionFiles files={rev.files} downloading={downloading} onDownloadAll={(f) => downloadAllFiles(f, rNum)} />
|
||||
@@ -664,7 +916,7 @@ export default function TaskDetail() {
|
||||
)}
|
||||
{activeTab === 'Submissions' && (
|
||||
reviewSubmissions.length === 0
|
||||
? <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 1, minHeight: 120, color: 'var(--text-muted)' }}>No submissions yet.</div>
|
||||
? <div className="card-empty-center">No submissions</div>
|
||||
: <div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
{reviewSubmissions.map((sub, i, arr) => {
|
||||
const fmtDate = sub.sent_at
|
||||
@@ -681,7 +933,7 @@ export default function TaskDetail() {
|
||||
<div style={META_LABEL}>Date</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{fmtDate}</div>
|
||||
</div>
|
||||
<div style={{ marginLeft: 'auto' }}>
|
||||
<div>
|
||||
<div style={META_LABEL}>Version</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)', fontWeight: 500 }}>{`R${String(sub.version_number ?? 0).padStart(2, '0')}`}</div>
|
||||
</div>
|
||||
@@ -713,8 +965,8 @@ export default function TaskDetail() {
|
||||
<button className="btn btn-outline" disabled={commentSaving || !commentBody.trim()} onClick={handlePostComment}>Post</button>
|
||||
</div>
|
||||
{comments.length === 0
|
||||
? <div style={{ color: 'var(--text-muted)', fontSize: 13 }}>No comments yet.</div>
|
||||
: comments.map((c, i) => {
|
||||
? <div className="card-empty-center">No comments</div>
|
||||
: commentRows.map((c, i) => {
|
||||
const d = new Date(c.created_at);
|
||||
const dateStr = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
const timeStr = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
|
||||
@@ -737,22 +989,6 @@ export default function TaskDetail() {
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'Folder' && company?.name && project?.name && (() => {
|
||||
const fbInitial = role === 'external'
|
||||
? `/Projects/${safeName(project.name)}/${safeName(task.title)}`
|
||||
: role === 'client'
|
||||
? `/${safeName(company.name)}/Projects/${safeName(project.name)}/${safeName(task.title)}`
|
||||
: `/Clients/${safeName(company.name)}/Projects/${safeName(project.name)}/${safeName(task.title)}`;
|
||||
const fbRoot = role === 'external'
|
||||
? `/Projects/${safeName(project.name)}`
|
||||
: role === 'client'
|
||||
? `/${safeName(company.name)}/Projects/${safeName(project.name)}`
|
||||
: `/Clients/${safeName(company.name)}/Projects/${safeName(project.name)}`;
|
||||
return <FileBrowser initialPath={fbInitial} rootPath={fbRoot} />;
|
||||
})()}
|
||||
{activeTab === 'Folder' && (!company?.name || !project?.name) && (
|
||||
<div style={{ color: 'var(--text-muted)' }}>No folder available — task needs a project and company.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -776,7 +1012,7 @@ export default function TaskDetail() {
|
||||
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={LABEL}>Activity</div>
|
||||
{activityLog.length === 0 ? (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No activity yet.</div>
|
||||
<div className="card-empty-center">No activity</div>
|
||||
) : (
|
||||
<div className="scrollbar-thin-theme" style={{ flex: 1, minHeight: 0, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{activityLog.map(e => (
|
||||
@@ -797,36 +1033,11 @@ export default function TaskDetail() {
|
||||
<textarea rows={4} placeholder="Add notes for this submission…" value={reviewNotes} onChange={e => setReviewNotes(e.target.value)} style={{ fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box', resize: 'vertical' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 6 }}>Attachments <span style={{ color: 'var(--text-muted)', fontWeight: 400, textTransform: 'none', letterSpacing: 0 }}>(optional)</span></div>
|
||||
<input ref={reviewFileRef} type="file" multiple id="review-file-input" style={{ display: 'none' }} onChange={e => { setReviewFiles(prev => [...prev, ...Array.from(e.target.files)]); e.target.value = ''; }} />
|
||||
<div
|
||||
onDragEnter={e => { e.preventDefault(); reviewDragCounter.current++; setReviewDragging(true); }}
|
||||
onDragLeave={e => { e.preventDefault(); reviewDragCounter.current--; if (reviewDragCounter.current === 0) setReviewDragging(false); }}
|
||||
onDragOver={e => e.preventDefault()}
|
||||
onDrop={e => { e.preventDefault(); reviewDragCounter.current = 0; setReviewDragging(false); setReviewFiles(prev => [...prev, ...Array.from(e.dataTransfer.files)]); }}
|
||||
onClick={() => reviewFileRef.current?.click()}
|
||||
style={{ border: `2px dashed ${reviewDragging ? 'var(--accent)' : reviewFiles.length > 0 ? 'var(--accent)' : 'var(--border)'}`, borderRadius: 8, padding: '16px', textAlign: 'center', background: reviewDragging ? 'color-mix(in srgb, var(--accent) 8%, var(--bg))' : 'transparent', transition: 'all 160ms', cursor: 'pointer' }}
|
||||
>
|
||||
<div style={{ fontSize: 20, marginBottom: 4 }}>{reviewDragging ? '📂' : '📎'}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>
|
||||
{reviewDragging ? 'Drop files here' : reviewFiles.length > 0 ? `${reviewFiles.length} file${reviewFiles.length !== 1 ? 's' : ''} attached — click or drag to add more` : 'Click or drag files here'}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 2 }}>Any file type accepted</div>
|
||||
</div>
|
||||
{reviewFiles.length > 0 && (
|
||||
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{reviewFiles.map((f, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '5px 10px', background: 'var(--card-bg-2)', borderRadius: 6, border: '1px solid var(--border)' }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-primary)' }}>{f.name}</span>
|
||||
<button type="button" className="btn btn-danger" onClick={() => setReviewFiles(prev => prev.filter((_, j) => j !== i))}>Remove</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<FileAttachment files={reviewFiles} onChange={setReviewFiles} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', paddingTop: 4 }}>
|
||||
<button className="btn btn-outline" disabled={reviewSaving} onClick={handleConfirmReview}>{reviewSaving ? 'Submitting…' : 'Place in Review'}</button>
|
||||
<button className="btn btn-outline" disabled={reviewSaving} onClick={() => { setReviewModal(false); setReviewFiles([]); setReviewNotes(''); }}>Cancel</button>
|
||||
<div className="modal-action-row" style={MODAL_ACTION_ROW}>
|
||||
<button className="btn btn-outline" style={MODAL_ACTION_BUTTON} disabled={reviewSaving} onClick={handleConfirmReview}>{reviewSaving ? 'Submitting…' : 'Place in Review'}</button>
|
||||
<button className="btn btn-outline" style={MODAL_ACTION_BUTTON} disabled={reviewSaving} onClick={() => { setReviewModal(false); setReviewFiles([]); setReviewNotes(''); }}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -858,9 +1069,9 @@ export default function TaskDetail() {
|
||||
<div className="form-group">
|
||||
<FileAttachment files={revisionFiles} onChange={setRevisionFiles} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button className="btn btn-outline" disabled={revisionSaving} onClick={() => { setRevisionModal(false); setRevisionForm({ description: '', deadline: '' }); setRevisionFiles([]); }}>Cancel</button>
|
||||
<button className="btn btn-outline" style={{ color: 'var(--accent)', borderColor: 'var(--accent)' }} disabled={revisionSaving || !revisionForm.description.trim()} onClick={handleSubmitRevision}>
|
||||
<div className="modal-action-row" style={MODAL_ACTION_ROW}>
|
||||
<button className="btn btn-outline" style={MODAL_ACTION_BUTTON} disabled={revisionSaving} onClick={() => { setRevisionModal(false); setRevisionForm({ description: '', deadline: '' }); setRevisionFiles([]); }}>Cancel</button>
|
||||
<button className="btn btn-outline" style={{ ...MODAL_ACTION_BUTTON, color: 'var(--accent)', borderColor: 'var(--accent)' }} disabled={revisionSaving || !revisionForm.description.trim()} onClick={handleSubmitRevision}>
|
||||
{revisionSaving ? 'Submitting…' : 'Submit Revision'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -868,6 +1079,30 @@ export default function TaskDetail() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rejectModal && (
|
||||
<div style={popupOverlayStyle} onClick={() => { if (!rejectSaving) { setRejectModal(false); setRejectNote(''); } }}>
|
||||
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', lineHeight: 1.1 }}>Reject Task</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 6 }}>Rejected Notes</div>
|
||||
<textarea
|
||||
rows={5}
|
||||
placeholder="Tell the team what needs to change before approval…"
|
||||
value={rejectNote}
|
||||
onChange={e => setRejectNote(e.target.value)}
|
||||
style={{ width: '100%', fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '8px 10px', resize: 'vertical', fontFamily: 'inherit', boxSizing: 'border-box' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="modal-action-row" style={MODAL_ACTION_ROW}>
|
||||
<button className="btn btn-outline" style={MODAL_ACTION_BUTTON} disabled={rejectSaving} onClick={() => { setRejectModal(false); setRejectNote(''); }}>Cancel</button>
|
||||
<button className="btn btn-danger" style={MODAL_ACTION_BUTTON} disabled={rejectSaving || !rejectNote.trim()} onClick={handleSubmitReject}>
|
||||
{rejectSaving ? 'Saving…' : 'Reject'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{amendModal && (
|
||||
<div style={popupOverlayStyle} onClick={() => { setAmendModal(false); setAmendForm({ description: '' }); setAmendFiles([]); }}>
|
||||
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}>
|
||||
@@ -878,35 +1113,11 @@ export default function TaskDetail() {
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 6 }}>Attach Files</div>
|
||||
<input ref={amendFileRef} type="file" multiple id="amend-file-input" style={{ display: 'none' }} onChange={e => { setAmendFiles(prev => [...prev, ...Array.from(e.target.files)]); e.target.value = ''; }} />
|
||||
<div
|
||||
onDragEnter={e => { e.preventDefault(); amendDragCounter.current++; setAmendDragging(true); }}
|
||||
onDragLeave={e => { e.preventDefault(); amendDragCounter.current--; if (amendDragCounter.current === 0) setAmendDragging(false); }}
|
||||
onDragOver={e => e.preventDefault()}
|
||||
onDrop={e => { e.preventDefault(); amendDragCounter.current = 0; setAmendDragging(false); setAmendFiles(prev => [...prev, ...Array.from(e.dataTransfer.files)]); }}
|
||||
style={{ border: `2px dashed ${amendDragging ? 'var(--accent)' : amendFiles.length > 0 ? 'var(--accent)' : 'var(--border)'}`, borderRadius: 8, padding: '16px', textAlign: 'center', background: amendDragging ? 'color-mix(in srgb, var(--accent) 8%, var(--bg))' : 'transparent', transition: 'all 160ms', cursor: 'pointer' }}
|
||||
onClick={() => amendFileRef.current?.click()}
|
||||
>
|
||||
<div style={{ fontSize: 20, marginBottom: 4 }}>{amendDragging ? '📂' : '📎'}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>
|
||||
{amendDragging ? 'Drop files here' : amendFiles.length > 0 ? `${amendFiles.length} file${amendFiles.length !== 1 ? 's' : ''} attached — click or drag to add more` : 'Click or drag files here'}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 2 }}>Any file type accepted</div>
|
||||
</div>
|
||||
{amendFiles.length > 0 && (
|
||||
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{amendFiles.map((f, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '5px 10px', background: 'var(--card-bg-2)', borderRadius: 6, border: '1px solid var(--border)' }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-primary)' }}>{f.name}</span>
|
||||
<button type="button" className="btn btn-danger" onClick={() => setAmendFiles(prev => prev.filter((_, idx) => idx !== i))}>Remove</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<FileAttachment files={amendFiles} onChange={setAmendFiles} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', paddingTop: 4 }}>
|
||||
<button className="btn btn-outline" disabled={amendSaving} onClick={handleSubmitAmendment}>{amendSaving ? 'Saving…' : 'Save'}</button>
|
||||
<button className="btn btn-outline" disabled={amendSaving} onClick={() => { setAmendModal(false); setAmendForm({ description: '' }); setAmendFiles([]); }}>Cancel</button>
|
||||
<div className="modal-action-row" style={MODAL_ACTION_ROW}>
|
||||
<button className="btn btn-outline" style={MODAL_ACTION_BUTTON} disabled={amendSaving} onClick={handleSubmitAmendment}>{amendSaving ? 'Saving…' : 'Save'}</button>
|
||||
<button className="btn btn-outline" style={MODAL_ACTION_BUTTON} disabled={amendSaving} onClick={() => { setAmendModal(false); setAmendForm({ description: '' }); setAmendFiles([]); }}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+238
-135
@@ -1,23 +1,36 @@
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Layout from '../components/Layout';
|
||||
import PageLoader from '../components/PageLoader';
|
||||
import ProfileAvatar from '../components/ProfileAvatar';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import RequestForm from '../components/RequestForm';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { readPageCache, writePageCache } from '../lib/pageCache';
|
||||
import { withTimeout } from '../lib/withTimeout';
|
||||
import { getCurrentVersionForTask, getDeadlineSourceSubmission } from '../lib/taskDeadlines';
|
||||
import { getTaskDerivedState } from '../lib/taskVersions';
|
||||
import { logActivity } from '../lib/activityLog';
|
||||
import { fmtShortDate } from '../lib/dates';
|
||||
import { createInitialSubmissionForRequest, createTaskForRequest, findOrCreateProject } from '../lib/requestSubmission';
|
||||
import { ensureProjectFolder, ensureRequestFolder, uploadRequestFileToSurvey } from '../lib/folderSync';
|
||||
import { sendEmail } from '../lib/email';
|
||||
import { uploadFilesToRequestInfo, createTaskFolder } from '../lib/filebrowserFolders';
|
||||
import SortTh from '../components/SortTh';
|
||||
import { useSortable } from '../hooks/useSortable';
|
||||
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
|
||||
import { useRefetchOnFocus } from '../hooks/useRefetchOnFocus';
|
||||
import { useRealtimeSubscription } from '../hooks/useRealtimeSubscription';
|
||||
import { popupOverlayStyle } from '../lib/popupStyles';
|
||||
import { useLiveRefresh } from '../hooks/useLiveRefresh';
|
||||
import { resolveScopedWorkIds } from '../lib/workScope';
|
||||
import { mergeSubmissionDisplayNames } from '../lib/submissionDisplay';
|
||||
import {
|
||||
TASK_TABLE_TH_STYLE,
|
||||
TASK_TABLE_TD_BASE,
|
||||
TASK_MODAL_TITLE_STYLE,
|
||||
TASK_MODAL_LABEL_STYLE,
|
||||
TASK_MODAL_INPUT_STYLE,
|
||||
TASK_MODAL_BUTTON_STYLE,
|
||||
TASK_MODAL_SHELL_STYLE,
|
||||
PROJECT_MODAL_SHELL_STYLE,
|
||||
} from '../lib/taskUi';
|
||||
|
||||
const TASK_STAT_ICONS = {
|
||||
total: '<rect x="3" y="4" width="18" height="16" rx="2"/><line x1="7" y1="9" x2="17" y2="9"/><line x1="7" y1="13" x2="17" y2="13"/>',
|
||||
@@ -28,19 +41,11 @@ const TASK_STAT_ICONS = {
|
||||
done: '<polyline points="4,12 9,17 20,6"/>',
|
||||
};
|
||||
|
||||
const TASK_TABLE_TH_STYLE = { fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.6, color: 'var(--text-muted)', textAlign: 'left', padding: '0 0 12px 5px', border: 'none', background: 'transparent', verticalAlign: 'top' };
|
||||
const TASK_TABLE_TD_BASE = { padding: '5px', border: 'none', background: 'transparent', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
|
||||
const TASK_MODAL_TITLE_STYLE = { fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' };
|
||||
const TASK_MODAL_LABEL_STYLE = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 6 };
|
||||
const TASK_MODAL_INPUT_STYLE = { fontSize: 13, padding: '0 5px', textAlign: 'left', lineHeight: 1 };
|
||||
const TASK_MODAL_BUTTON_STYLE = { lineHeight: 1, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' };
|
||||
const TASK_MODAL_SHELL_STYLE = { ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(720px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto' };
|
||||
const PROJECT_MODAL_SHELL_STYLE = { ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(434px, 100%)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto' };
|
||||
const TASK_STATUS_SORT_RANK = { not_started: 0, in_progress: 1, on_hold: 2, client_review: 3, client_approved: 4, invoiced: 5, paid: 6 };
|
||||
|
||||
function TaskStatCard({ label, value, iconBg, iconColor, iconPath }) {
|
||||
function TaskStatCard({ label, value, sub, iconBg, iconColor, iconPath }) {
|
||||
return (
|
||||
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '14px 21px', minHeight: 86 }}>
|
||||
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', minHeight: 120 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
|
||||
<div style={{ width: 27, height: 27, borderRadius: '50%', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
@@ -48,6 +53,7 @@ function TaskStatCard({ label, value, iconBg, iconColor, iconPath }) {
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{value}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 5 }}>{sub}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -58,14 +64,17 @@ function TasksStatsRow({ tasks = [] }) {
|
||||
const onHold = tasks.filter(t => t?.status === 'on_hold').length;
|
||||
const inReview = tasks.filter(t => t?.status === 'client_review').length;
|
||||
const completed = tasks.filter(t => ['client_approved', 'invoiced', 'paid'].includes(t?.status)).length;
|
||||
const total = tasks.length;
|
||||
const open = Math.max(total - completed, 0);
|
||||
const pct = (count) => total > 0 ? `${Math.round((count / total) * 100)}% of total` : '0% of total';
|
||||
return (
|
||||
<div className="dash-stat-grid" style={{ gridTemplateColumns: '1fr 1fr 1fr 1fr 1fr 1fr' }}>
|
||||
<TaskStatCard label="To Do" value={toDo} iconBg="rgba(167,139,250,0.15)" iconColor="#a78bfa" iconPath={TASK_STAT_ICONS.todo} />
|
||||
<TaskStatCard label="In Progress" value={inProgress} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconPath={TASK_STAT_ICONS.progress} />
|
||||
<TaskStatCard label="On Hold" value={onHold} iconBg="rgba(239,68,68,0.15)" iconColor="#ef4444" iconPath={TASK_STAT_ICONS.hold} />
|
||||
<TaskStatCard label="In Review" value={inReview} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={TASK_STAT_ICONS.review} />
|
||||
<TaskStatCard label="Completed" value={completed} iconBg="rgba(74,222,128,0.15)" iconColor="#4ade80" iconPath={TASK_STAT_ICONS.done} />
|
||||
<TaskStatCard label="Total Tasks" value={tasks.length} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={TASK_STAT_ICONS.total} />
|
||||
<TaskStatCard label="To Do" value={toDo} sub={pct(toDo)} iconBg="rgba(167,139,250,0.15)" iconColor="#a78bfa" iconPath={TASK_STAT_ICONS.todo} />
|
||||
<TaskStatCard label="In Progress" value={inProgress} sub={pct(inProgress)} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconPath={TASK_STAT_ICONS.progress} />
|
||||
<TaskStatCard label="On Hold" value={onHold} sub={pct(onHold)} iconBg="rgba(239,68,68,0.15)" iconColor="#ef4444" iconPath={TASK_STAT_ICONS.hold} />
|
||||
<TaskStatCard label="In Review" value={inReview} sub={pct(inReview)} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={TASK_STAT_ICONS.review} />
|
||||
<TaskStatCard label="Completed" value={completed} sub={pct(completed)} iconBg="rgba(74,222,128,0.15)" iconColor="#4ade80" iconPath={TASK_STAT_ICONS.done} />
|
||||
<TaskStatCard label="Total Tasks" value={total} sub={`${open} still open`} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={TASK_STAT_ICONS.total} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -80,7 +89,7 @@ function TasksPageShell({ statsTasks = [], projects = [], onAddProject = null, c
|
||||
};
|
||||
const completedStatuses = new Set(['completed', 'cancelled', 'archived', 'done']);
|
||||
const projectTabs = [
|
||||
{ id: 'all', label: 'All Projects' },
|
||||
{ id: 'all', label: 'All' },
|
||||
{ id: 'active', label: 'Active' },
|
||||
{ id: 'completed', label: 'Completed' },
|
||||
];
|
||||
@@ -89,6 +98,11 @@ function TasksPageShell({ statsTasks = [], projects = [], onAddProject = null, c
|
||||
const done = completedStatuses.has((p?.status || '').toLowerCase());
|
||||
return projTab === 'completed' ? done : !done;
|
||||
});
|
||||
const projectTabCounts = {
|
||||
all: projects.length,
|
||||
active: projects.filter((p) => !completedStatuses.has((p?.status || '').toLowerCase())).length,
|
||||
completed: projects.filter((p) => completedStatuses.has((p?.status || '').toLowerCase())).length,
|
||||
};
|
||||
const sortedProjects = [...visibleProjects].sort((a, b) => {
|
||||
if (projSortKey === 'status') {
|
||||
const ra = completedStatuses.has((a.status || '').toLowerCase()) ? 1 : 0;
|
||||
@@ -119,7 +133,14 @@ function TasksPageShell({ statsTasks = [], projects = [], onAddProject = null, c
|
||||
<div style={{ flex: '0 0 calc(30% - 24px)', minWidth: 0, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10, flexShrink: 0 }}>
|
||||
{projectTabs.map(t => (
|
||||
<button key={t.id} onClick={() => setProjTab(t.id)} className={`section-tab-btn${projTab === t.id ? ' is-active' : ''}`}>{t.label}</button>
|
||||
<button key={t.id} onClick={() => setProjTab(t.id)} className={`section-tab-btn${projTab === t.id ? ' is-active' : ''}`}>
|
||||
{t.label}
|
||||
{projectTabCounts[t.id] > 0 && (
|
||||
<span style={{ marginLeft: 5, fontSize: 12, fontWeight: 600, color: projTab === t.id ? 'var(--accent)' : 'var(--text-muted)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 10, padding: '3px 8px' }}>
|
||||
{projectTabCounts[t.id]}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{onAddProject && (
|
||||
<div style={{ marginLeft: 'auto' }}>
|
||||
@@ -128,39 +149,41 @@ function TasksPageShell({ statsTasks = [], projects = [], onAddProject = null, c
|
||||
)}
|
||||
</div>
|
||||
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', height: '100%', boxSizing: 'border-box', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||
<colgroup>
|
||||
<col style={{ width: '60%' }} />
|
||||
<col style={{ width: '40%' }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="name" sortKey={projSortKey} sortDir={projSortDir} onSort={toggleProjSort} style={TASK_TABLE_TH_STYLE}>Name</SortTh>
|
||||
<SortTh col="status" sortKey={projSortKey} sortDir={projSortDir} onSort={toggleProjSort} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Status</SortTh>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedProjects.length === 0 ? (
|
||||
<tr><td colSpan={2} style={{ fontSize: 13, color: 'var(--text-muted)', padding: '28px 5px', border: 'none', background: 'transparent', textAlign: 'center' }}>No projects.</td></tr>
|
||||
) : sortedProjects.map(p => (
|
||||
<tr key={p.id}>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)', textAlign: 'left' }}>
|
||||
<Link to={`/projects/${p.id}`} className="table-link">{p.name}</Link>
|
||||
</td>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-muted)', textAlign: 'center' }}>
|
||||
<Link to={`/projects/${p.id}`} className="table-link" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6, width: '100%' }}>
|
||||
<span style={{ width: 68, height: 4, borderRadius: 2, background: 'var(--border)', overflow: 'hidden', position: 'relative', flexShrink: 0 }}>
|
||||
<span style={{ position: 'absolute', inset: 0, width: `${projectCompletionPct(p)}%`, background: 'var(--accent)', borderRadius: 2 }} />
|
||||
</span>
|
||||
<span style={{ minWidth: 28, textAlign: 'right', fontSize: 12, color: 'var(--text-secondary)' }}>{projectCompletionPct(p)}%</span>
|
||||
</Link>
|
||||
</td>
|
||||
{sortedProjects.length === 0 ? (
|
||||
<div className="card-empty-center">No projects</div>
|
||||
) : (
|
||||
<div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||
<colgroup>
|
||||
<col style={{ width: '60%' }} />
|
||||
<col style={{ width: '40%' }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="name" sortKey={projSortKey} sortDir={projSortDir} onSort={toggleProjSort} style={TASK_TABLE_TH_STYLE}>Name</SortTh>
|
||||
<SortTh col="status" sortKey={projSortKey} sortDir={projSortDir} onSort={toggleProjSort} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Status</SortTh>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedProjects.map(p => (
|
||||
<tr key={p.id}>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)', textAlign: 'left' }}>
|
||||
<Link to={`/projects/${p.id}`} className="table-link">{p.name}</Link>
|
||||
</td>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-muted)', textAlign: 'center' }}>
|
||||
<Link to={`/projects/${p.id}`} className="table-link" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6, width: '100%' }}>
|
||||
<span style={{ width: 68, height: 4, borderRadius: 2, background: 'var(--border)', overflow: 'hidden', position: 'relative', flexShrink: 0 }}>
|
||||
<span style={{ position: 'absolute', inset: 0, width: `${projectCompletionPct(p)}%`, background: 'var(--accent)', borderRadius: 2 }} />
|
||||
</span>
|
||||
<span style={{ minWidth: 28, textAlign: 'right', fontSize: 12, color: 'var(--text-secondary)' }}>{projectCompletionPct(p)}%</span>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -174,10 +197,7 @@ export default function RequestsPage() {
|
||||
const isExternal = currentUser?.role === 'external';
|
||||
const isClient = currentUser?.role === 'client';
|
||||
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const refresh = useCallback(() => setRefreshKey(k => k + 1), []);
|
||||
useRefetchOnFocus(refresh);
|
||||
useRealtimeSubscription(['tasks', 'projects', 'submissions'], refresh);
|
||||
const { refreshKey } = useLiveRefresh(['tasks', 'projects', 'submissions']);
|
||||
|
||||
const teamCached = isTeam ? readPageCache('team_requests') : null;
|
||||
const extCached = isExternal ? readPageCache(`ext-requests:${currentUser?.id}`, 3 * 60_000) : null;
|
||||
@@ -185,6 +205,7 @@ export default function RequestsPage() {
|
||||
const [projects, setProjects] = useState(() => teamCached?.projects || extCached?.projects || []);
|
||||
const [tasks, setTasks] = useState(() => teamCached?.tasks || extCached?.tasks || []);
|
||||
const [submissions, setSubmissions] = useState(() => teamCached?.submissions || extCached?.submissions || []);
|
||||
const [deliveries, setDeliveries] = useState([]);
|
||||
const [companies, setCompanies] = useState(() => teamCached?.companies || []);
|
||||
const [loading, setLoading] = useState(() => {
|
||||
if (isTeam) return !teamCached;
|
||||
@@ -193,7 +214,7 @@ export default function RequestsPage() {
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('not_started');
|
||||
const [activeTab, setActiveTab] = useState('new_requests');
|
||||
const [filterCompany, setFilterCompany] = useState('');
|
||||
const [filterProject, setFilterProject] = useState('');
|
||||
const { sortKey, sortDir, toggle, sort } = useSortable('status', 'asc');
|
||||
@@ -238,37 +259,11 @@ export default function RequestsPage() {
|
||||
try {
|
||||
setError(''); setLoadError(false);
|
||||
|
||||
let scopedProjectIds = null;
|
||||
let scopedTaskIds = null;
|
||||
|
||||
if (isClient) {
|
||||
const clientCos = currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : []);
|
||||
const companyIds = clientCos.map(c => c.id).filter(Boolean);
|
||||
if (companyIds.length > 0) {
|
||||
const { data: projRows } = await withTimeout(
|
||||
supabase.from('projects').select('id, tasks(id)').in('company_id', companyIds), 10000, 'Client project scope'
|
||||
);
|
||||
scopedProjectIds = (projRows || []).map(p => p.id);
|
||||
scopedTaskIds = (projRows || []).flatMap(p => (p.tasks || []).map(t => t.id));
|
||||
} else {
|
||||
scopedProjectIds = []; scopedTaskIds = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (isExternal) {
|
||||
const { data: memberData } = await withTimeout(
|
||||
supabase.from('project_members').select('project_id').eq('profile_id', currentUser.id), 10000, 'External project scope'
|
||||
);
|
||||
scopedProjectIds = (memberData || []).map(r => r.project_id).filter(Boolean);
|
||||
if (scopedProjectIds.length > 0) {
|
||||
const { data: taskRows } = await withTimeout(
|
||||
supabase.from('tasks').select('id').in('project_id', scopedProjectIds), 10000, 'External task scope'
|
||||
);
|
||||
scopedTaskIds = (taskRows || []).map(r => r.id).filter(Boolean);
|
||||
} else {
|
||||
scopedTaskIds = [];
|
||||
}
|
||||
}
|
||||
const { scopedProjectIds, scopedTaskIds } = await withTimeout(
|
||||
resolveScopedWorkIds(currentUser, { isClient, isExternal }),
|
||||
10000,
|
||||
'Tasks scope'
|
||||
);
|
||||
|
||||
const tasksQ = supabase.from('tasks')
|
||||
.select('id, title, status, current_version, project_id, assigned_name, assigned_to, invoiced, completed_at, submitted_at, assignee:profiles!assigned_to(avatar_url)')
|
||||
@@ -298,12 +293,38 @@ export default function RequestsPage() {
|
||||
);
|
||||
if (cancelled) return;
|
||||
|
||||
setProjects(p || []); setTasks(t || []); setSubmissions(subs || []); setCompanies(co || []);
|
||||
const allSubs = subs || [];
|
||||
const submitterIds = [...new Set(allSubs.map((submission) => submission.submitted_by).filter(Boolean))];
|
||||
let submitterProfiles = [];
|
||||
if (submitterIds.length > 0) {
|
||||
const { data: profileRows } = await withTimeout(
|
||||
supabase.from('profiles').select('id, name').in('id', submitterIds),
|
||||
10000,
|
||||
'Tasks submitter profiles load'
|
||||
);
|
||||
submitterProfiles = profileRows || [];
|
||||
}
|
||||
const hydratedSubs = mergeSubmissionDisplayNames(allSubs, submitterProfiles);
|
||||
let deliveryRows = [];
|
||||
if (hydratedSubs.length > 0) {
|
||||
const { data: delRows } = await withTimeout(
|
||||
supabase.from('deliveries').select('id, submission_id, version_number, sent_at').in('submission_id', hydratedSubs.map(sub => sub.id)),
|
||||
10000,
|
||||
'Tasks deliveries load'
|
||||
);
|
||||
deliveryRows = delRows || [];
|
||||
}
|
||||
|
||||
setProjects(p || []);
|
||||
setTasks(t || []);
|
||||
setSubmissions(hydratedSubs);
|
||||
setDeliveries(deliveryRows);
|
||||
setCompanies(co || []);
|
||||
|
||||
if (isTeam) {
|
||||
writePageCache('team_requests', { submissions: subs || [], tasks: t || [], projects: p || [], companies: co || [] });
|
||||
writePageCache('team_requests', { submissions: hydratedSubs, tasks: t || [], projects: p || [], companies: co || [] });
|
||||
} else if (isExternal) {
|
||||
writePageCache(`ext-requests:${currentUser.id}`, { projects: p || [], tasks: t || [], submissions: subs || [] });
|
||||
writePageCache(`ext-requests:${currentUser.id}`, { projects: p || [], tasks: t || [], submissions: hydratedSubs });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Tasks page load failed:', err);
|
||||
@@ -317,7 +338,7 @@ export default function RequestsPage() {
|
||||
return () => { cancelled = true; };
|
||||
}, [isTeam, isExternal, isClient, currentUser?.id, refreshKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleAddRequest = async (formData, _files, existingProjects) => {
|
||||
const handleAddRequest = async (formData, files, existingProjects) => {
|
||||
if (addSaving) return;
|
||||
setAddSaving(true); setAddError('');
|
||||
try {
|
||||
@@ -326,7 +347,6 @@ export default function RequestsPage() {
|
||||
const { task } = await createTaskForRequest({ projectId: resolvedProject.id, title: formData.title.trim(), requestKey: addRequestKey });
|
||||
if (!task) throw new Error('Failed to create task.');
|
||||
const taskCompany = companies?.find(c => c.id === formData.companyId);
|
||||
createTaskFolder(taskCompany?.name, resolvedProject.name, formData.title.trim()).catch(() => {});
|
||||
const { submission: sub } = await createInitialSubmissionForRequest({
|
||||
taskId: task.id, requestKey: addRequestKey, isHot: formData.isHot,
|
||||
serviceType: formData.serviceType, signFamily: formData.signFamily,
|
||||
@@ -335,12 +355,67 @@ export default function RequestsPage() {
|
||||
description: formData.description, submittedBy: formData.requestedBy, submittedByName: formData.requestedByName,
|
||||
});
|
||||
if (!sub) throw new Error('Failed to create submission.');
|
||||
try {
|
||||
await ensureRequestFolder({ projectId: resolvedProject.id, taskTitle: formData.title.trim() });
|
||||
} catch (folderError) {
|
||||
console.error('Request folder sync failed:', folderError);
|
||||
}
|
||||
if (files.length > 0) {
|
||||
const taskCompany = companies?.find(c => c.id === formData.companyId);
|
||||
setShowAddForm(false);
|
||||
setUploadProgress({ active: true, label: 'Uploading files…', percent: 0 });
|
||||
for (let fi = 0; fi < files.length; fi++) {
|
||||
const file = files[fi];
|
||||
setUploadProgress({ active: true, label: file.name, percent: Math.round((fi / files.length) * 100) });
|
||||
const path = `${task.id}/Survey/${Date.now()}_${file.name}`;
|
||||
const { data: uploaded, error: uploadError } = await supabase.storage.from('submissions').upload(path, file);
|
||||
if (uploadError) {
|
||||
await supabase.from('tasks').delete().eq('id', task.id);
|
||||
setUploadProgress({ active: false, label: '', percent: 0 });
|
||||
throw new Error(`Upload failed: ${uploadError.message}`);
|
||||
}
|
||||
if (uploaded) {
|
||||
const { error: fileErr } = await supabase.from('submission_files').insert({ submission_id: sub.id, name: file.name, storage_path: path, size: file.size });
|
||||
if (fileErr) {
|
||||
await supabase.from('tasks').delete().eq('id', task.id);
|
||||
setUploadProgress({ active: false, label: '', percent: 0 });
|
||||
throw new Error(`File record failed: ${fileErr.message}`);
|
||||
}
|
||||
try {
|
||||
await uploadRequestFileToSurvey({
|
||||
projectId: resolvedProject.id,
|
||||
taskTitle: formData.title.trim(),
|
||||
storagePath: path,
|
||||
fileName: file.name,
|
||||
});
|
||||
} catch (mirrorError) {
|
||||
console.error('Survey folder mirror failed:', mirrorError);
|
||||
}
|
||||
}
|
||||
}
|
||||
setUploadProgress({ active: false, label: '', percent: 0 });
|
||||
}
|
||||
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'request_submitted', taskId: task.id, taskTitle: task.title, projectId: resolvedProject.id, projectName: resolvedProject.name }).catch(() => {});
|
||||
sendEmail('new_request', 'hello@fourgebranding.com', {
|
||||
clientName: formData.requestedByName || currentUser.name,
|
||||
clientEmail: currentUser.email || 'hello@fourgebranding.com',
|
||||
company: taskCompany?.name || '',
|
||||
serviceType: formData.serviceType,
|
||||
projectName: resolvedProject.name,
|
||||
projectId: resolvedProject.id,
|
||||
deadline: formData.deadline,
|
||||
description: formData.description,
|
||||
taskId: task.id,
|
||||
}).catch(() => {});
|
||||
const [{ data: newSubs }, { data: newTasks }] = await Promise.all([
|
||||
supabase.from('submissions').select('id, task_id, submitted_at, submitted_by, submitted_by_name, is_hot, service_type, deadline, version_number, type').order('submitted_at', { ascending: false }),
|
||||
supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_name, assigned_to, invoiced, completed_at, submitted_at, assignee:profiles!assigned_to(avatar_url)').order('submitted_at', { ascending: false }),
|
||||
]);
|
||||
setSubmissions(newSubs || []); setTasks(newTasks || []);
|
||||
const submitterIds = [...new Set((newSubs || []).map((submission) => submission.submitted_by).filter(Boolean))];
|
||||
const { data: submitterProfiles } = submitterIds.length > 0
|
||||
? await supabase.from('profiles').select('id, name').in('id', submitterIds)
|
||||
: { data: [] };
|
||||
setSubmissions(mergeSubmissionDisplayNames(newSubs || [], submitterProfiles || [])); setTasks(newTasks || []);
|
||||
setShowAddForm(false); setAddFormKey(k => k + 1); setAddRequestKey(crypto.randomUUID());
|
||||
} catch (err) { setAddError(err.message); }
|
||||
finally { setAddSaving(false); }
|
||||
@@ -355,7 +430,6 @@ export default function RequestsPage() {
|
||||
const resolvedProject = await findOrCreateProject(formData.companyId, formData.project.trim(), existingProjects);
|
||||
const { task } = await createTaskForRequest({ projectId: resolvedProject.id, title: formData.title.trim(), requestKey: addRequestKey });
|
||||
if (!task) throw new Error('Failed to create task.');
|
||||
createTaskFolder(selectedCompany?.name, resolvedProject.name, formData.title.trim()).catch(() => {});
|
||||
const { submission } = await createInitialSubmissionForRequest({
|
||||
taskId: task.id, requestKey: addRequestKey, isHot: formData.isHot,
|
||||
serviceType: formData.serviceType, signFamily: formData.signFamily,
|
||||
@@ -363,28 +437,42 @@ export default function RequestsPage() {
|
||||
deadline: formData.deadline,
|
||||
description: formData.description, submittedBy: currentUser.id, submittedByName: currentUser.name,
|
||||
});
|
||||
try {
|
||||
await ensureRequestFolder({ projectId: resolvedProject.id, taskTitle: formData.title.trim() });
|
||||
} catch (folderError) {
|
||||
console.error('Request folder sync failed:', folderError);
|
||||
}
|
||||
if (submission && files.length > 0) {
|
||||
setShowAddForm(false);
|
||||
setUploadProgress({ active: true, label: 'Uploading files…', percent: 0 });
|
||||
for (let fi = 0; fi < files.length; fi++) {
|
||||
const file = files[fi];
|
||||
setUploadProgress({ active: true, label: file.name, percent: Math.round((fi / files.length) * 100) });
|
||||
const path = `${task.id}/${Date.now()}_${file.name}`;
|
||||
const path = `${task.id}/Survey/${Date.now()}_${file.name}`;
|
||||
const { data: uploaded, error: uploadError } = await supabase.storage.from('submissions').upload(path, file);
|
||||
if (uploadError) { await supabase.from('tasks').delete().eq('id', task.id); setUploadProgress({ active: false, label: '', percent: 0 }); throw new Error(`Upload failed: ${uploadError.message}`); }
|
||||
if (uploaded) {
|
||||
const { error: fileErr } = await supabase.from('submission_files').insert({ submission_id: submission.id, name: file.name, storage_path: path, size: file.size });
|
||||
if (fileErr) { await supabase.from('tasks').delete().eq('id', task.id); setUploadProgress({ active: false, label: '', percent: 0 }); throw new Error(`File record failed: ${fileErr.message}`); }
|
||||
try {
|
||||
await uploadRequestFileToSurvey({
|
||||
projectId: resolvedProject.id,
|
||||
taskTitle: formData.title.trim(),
|
||||
storagePath: path,
|
||||
fileName: file.name,
|
||||
});
|
||||
} catch (mirrorError) {
|
||||
console.error('Survey folder mirror failed:', mirrorError);
|
||||
}
|
||||
}
|
||||
}
|
||||
uploadFilesToRequestInfo(files, selectedCompany?.name, resolvedProject.name, formData.title.trim()).catch(() => {});
|
||||
setUploadProgress({ active: false, label: '', percent: 0 });
|
||||
}
|
||||
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'request_submitted', taskId: task.id, taskTitle: task.title, projectId: resolvedProject.id, projectName: resolvedProject.name }).catch(() => {});
|
||||
sendEmail('new_request', 'hello@fourgebranding.com', {
|
||||
clientName: currentUser.name, clientEmail: currentUser.email,
|
||||
company: selectedCompany?.name || '', serviceType: formData.serviceType,
|
||||
projectName: formData.project, deadline: formData.deadline,
|
||||
projectName: formData.project, projectId: resolvedProject.id, deadline: formData.deadline,
|
||||
description: formData.description, taskId: task.id,
|
||||
}).catch(() => {});
|
||||
const refreshCompanyIds = clientCos.map(c => c.id).filter(Boolean);
|
||||
@@ -399,7 +487,11 @@ export default function RequestsPage() {
|
||||
.select('id, task_id, submitted_at, submitted_by, submitted_by_name, is_hot, service_type, deadline, version_number, type')
|
||||
.in('task_id', refreshTaskIds.length > 0 ? refreshTaskIds : ['__none__'])
|
||||
.order('submitted_at', { ascending: false });
|
||||
setProjects(refreshProjects || []); setTasks(newTasks || []); setSubmissions(newSubs || []);
|
||||
const submitterIds = [...new Set((newSubs || []).map((submission) => submission.submitted_by).filter(Boolean))];
|
||||
const { data: submitterProfiles } = submitterIds.length > 0
|
||||
? await supabase.from('profiles').select('id, name').in('id', submitterIds)
|
||||
: { data: [] };
|
||||
setProjects(refreshProjects || []); setTasks(newTasks || []); setSubmissions(mergeSubmissionDisplayNames(newSubs || [], submitterProfiles || []));
|
||||
setShowAddForm(false); setAddFormKey(k => k + 1); setAddRequestKey(crypto.randomUUID());
|
||||
} catch (err) { setAddError(err.message); }
|
||||
finally { setAddSaving(false); }
|
||||
@@ -417,6 +509,11 @@ export default function RequestsPage() {
|
||||
.from('projects').insert({ name, company_id: addProjectForm.companyId, status: 'active' })
|
||||
.select('id, name, status, company_id, company:companies(id, name)').single();
|
||||
if (insertError) throw insertError;
|
||||
try {
|
||||
await ensureProjectFolder({ projectId: newProject.id });
|
||||
} catch (folderError) {
|
||||
console.error('Project folder sync failed:', folderError);
|
||||
}
|
||||
setProjects(prev => [...prev, newProject]);
|
||||
setShowAddProjectForm(false); setAddProjectForm({ name: '', companyId: '' });
|
||||
} catch (err) { setAddProjectError(err.message || 'Failed to create project.'); }
|
||||
@@ -436,37 +533,29 @@ export default function RequestsPage() {
|
||||
const allRows = useMemo(() => {
|
||||
if (isClient) {
|
||||
return tasks.map(task => {
|
||||
const sub = submissions.find(s => s.task_id === task.id && s.type === 'initial');
|
||||
const derived = getTaskDerivedState(task, submissions, deliveries);
|
||||
return {
|
||||
id: task.id, title: task.title, status: task.status, projectId: task.project_id,
|
||||
serviceType: sub?.service_type || '—', deadline: sub?.deadline || null,
|
||||
version: task.current_version || 0, isHot: false,
|
||||
serviceType: derived.serviceType, deadline: derived.initialSubmission?.deadline || derived.deadline || null,
|
||||
version: derived.currentVersion, isHot: false,
|
||||
assignedName: task.assigned_name || null, assigneeAvatar: task.assignee?.avatar_url || null, assignedTo: task.assigned_to || null,
|
||||
submittedAt: task.submitted_at ? new Date(task.submitted_at).getTime() : 0,
|
||||
submittedAt: derived.latestActivityAt,
|
||||
};
|
||||
});
|
||||
}
|
||||
return tasks.map(task => {
|
||||
const taskSubs = submissions.filter(s => s.task_id === task.id);
|
||||
const deadlineSource = getDeadlineSourceSubmission(task, taskSubs);
|
||||
if (!deadlineSource) return null;
|
||||
const currentVersion = getCurrentVersionForTask(task, taskSubs);
|
||||
const latestGroup = taskSubs.filter(s => s.version_number === currentVersion);
|
||||
const serviceType = submissions.find(s => s.task_id === task.id && s.type === 'initial')?.service_type
|
||||
|| submissions.find(s => s.task_id === task.id && s.service_type)?.service_type
|
||||
|| deadlineSource.service_type || '—';
|
||||
const derived = getTaskDerivedState(task, submissions, deliveries);
|
||||
if (derived.visibleTaskSubs.length === 0 || !derived.deadlineSource) return null;
|
||||
return {
|
||||
id: task.id, title: task.title, status: task.status, projectId: task.project_id,
|
||||
serviceType, deadline: deadlineSource.deadline,
|
||||
version: deadlineSource.version_number ?? 0,
|
||||
isHot: deadlineSource.is_hot || false,
|
||||
serviceType: derived.serviceType, deadline: derived.deadline,
|
||||
version: derived.currentVersion,
|
||||
isHot: derived.isHot,
|
||||
assignedName: task.assigned_name || null, assigneeAvatar: task.assignee?.avatar_url || null, assignedTo: task.assigned_to || null,
|
||||
submittedAt: latestGroup.length > 0
|
||||
? Math.max(...latestGroup.map(s => new Date(s.submitted_at).getTime()))
|
||||
: (task.submitted_at ? new Date(task.submitted_at).getTime() : 0),
|
||||
submittedAt: derived.latestActivityAt,
|
||||
};
|
||||
}).filter(Boolean);
|
||||
}, [tasks, submissions, isClient]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [tasks, submissions, deliveries, isClient]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
return allRows
|
||||
@@ -482,16 +571,29 @@ export default function RequestsPage() {
|
||||
const doneStatuses = new Set(['client_approved', 'invoiced', 'paid']);
|
||||
|
||||
const tabs = [
|
||||
{ id: 'all', label: 'All Tasks' },
|
||||
{ id: 'not_started', label: 'To Do' },
|
||||
{ id: 'all', label: 'All' },
|
||||
{ id: 'new_requests', label: 'New Requests' },
|
||||
{ id: 'revisions', label: 'Revisions' },
|
||||
{ id: 'in_progress', label: 'In Progress' },
|
||||
{ id: 'on_hold', label: 'On Hold' },
|
||||
{ id: 'client_review', label: 'In Review' },
|
||||
{ id: 'completed', label: 'Completed' },
|
||||
];
|
||||
|
||||
const tabRows = activeTab === 'all' ? filteredRows
|
||||
const tabCounts = {
|
||||
all: filteredRows.length,
|
||||
new_requests: filteredRows.filter(r => r.status === 'not_started' && Number(r.version || 0) === 0).length,
|
||||
revisions: filteredRows.filter(r => r.status === 'not_started' && Number(r.version || 0) >= 1).length,
|
||||
in_progress: filteredRows.filter(r => r.status === 'in_progress').length,
|
||||
on_hold: filteredRows.filter(r => r.status === 'on_hold').length,
|
||||
client_review: filteredRows.filter(r => r.status === 'client_review').length,
|
||||
completed: filteredRows.filter(r => doneStatuses.has(r.status)).length,
|
||||
};
|
||||
|
||||
const tabRows = activeTab === 'all' ? filteredRows
|
||||
: activeTab === 'completed' ? filteredRows.filter(r => doneStatuses.has(r.status))
|
||||
: activeTab === 'new_requests' ? filteredRows.filter(r => r.status === 'not_started' && Number(r.version || 0) === 0)
|
||||
: activeTab === 'revisions' ? filteredRows.filter(r => r.status === 'not_started' && Number(r.version || 0) >= 1)
|
||||
: filteredRows.filter(r => r.status === activeTab);
|
||||
|
||||
const sortedRows = sort(tabRows, (row, key) => {
|
||||
@@ -517,7 +619,6 @@ export default function RequestsPage() {
|
||||
|
||||
const renderRow = (row) => {
|
||||
const project = projects.find(p => p.id === row.projectId);
|
||||
const initials = row.assignedName ? row.assignedName.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase() : null;
|
||||
return (
|
||||
<tr key={row.id}>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>
|
||||
@@ -531,14 +632,11 @@ export default function RequestsPage() {
|
||||
</td>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, textAlign: 'center' }}>
|
||||
{(() => {
|
||||
const avatarStyle = { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, borderRadius: '50%', overflow: 'hidden', verticalAlign: 'middle', flexShrink: 0 };
|
||||
if (row.assignedName && row.assignedTo) {
|
||||
const portrait = row.assigneeAvatar
|
||||
? <div title={row.assignedName} style={avatarStyle}><img src={row.assigneeAvatar} alt={row.assignedName} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /></div>
|
||||
: <div title={row.assignedName} style={{ ...avatarStyle, background: 'var(--accent)' }}><span style={{ fontSize: 10, fontWeight: 600, color: '#000', lineHeight: 1 }}>{initials}</span></div>;
|
||||
const portrait = <ProfileAvatar name={row.assignedName} avatarUrl={row.assigneeAvatar} size={26} fontSize={10} />;
|
||||
return <Link to={`/profile/${row.assignedTo}`} style={{ display: 'inline-flex' }}>{portrait}</Link>;
|
||||
}
|
||||
return <div title="Unassigned" style={{ ...avatarStyle, background: 'rgba(255,255,255,0.08)' }}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.3)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg></div>;
|
||||
return <div title="Unassigned" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, borderRadius: '50%', overflow: 'hidden', verticalAlign: 'middle', flexShrink: 0, background: 'rgba(255,255,255,0.08)' }}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.3)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg></div>;
|
||||
})()}
|
||||
</td>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 11, textAlign: 'center', color: row.isHot ? '#ef4444' : 'var(--text-primary)', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
@@ -554,7 +652,7 @@ export default function RequestsPage() {
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
if (loading) return <Layout><PageLoader /></Layout>;
|
||||
if (loadError) return <Layout><div style={{ padding: 24 }}><p style={{ color: 'var(--text-muted)', marginBottom: 12 }}>Failed to load. Check your connection and try again.</p><button className="btn btn-outline" onClick={() => window.location.reload()}>Retry</button></div></Layout>;
|
||||
|
||||
const filteredStatTasks = filteredRows.map(r => tasks.find(t => t.id === r.id)).filter(Boolean);
|
||||
@@ -590,7 +688,7 @@ export default function RequestsPage() {
|
||||
<input type="text" placeholder="e.g. Brand Identity 2026" value={addProjectForm.name} onChange={e => setAddProjectForm(f => ({ ...f, name: e.target.value }))} required autoFocus style={TASK_MODAL_INPUT_STYLE} />
|
||||
</div>
|
||||
{addProjectError && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>{addProjectError}</div>}
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 20 }}>
|
||||
<div className="modal-action-row" style={{ marginTop: 20 }}>
|
||||
<button type="submit" className="btn btn-outline" style={TASK_MODAL_BUTTON_STYLE} disabled={addProjectSaving}>{addProjectSaving ? 'Saving...' : 'Save'}</button>
|
||||
<button type="button" className="btn btn-outline" style={TASK_MODAL_BUTTON_STYLE} onClick={() => { setShowAddProjectForm(false); setAddProjectForm({ name: '', companyId: '' }); setAddProjectError(''); }}>Cancel</button>
|
||||
</div>
|
||||
@@ -627,6 +725,11 @@ export default function RequestsPage() {
|
||||
{tabs.map(t => (
|
||||
<button key={t.id} onClick={() => setActiveTab(t.id)} className={`section-tab-btn${activeTab === t.id ? ' is-active' : ''}`}>
|
||||
{t.label}
|
||||
{tabCounts[t.id] > 0 && (
|
||||
<span style={{ marginLeft: 5, fontSize: 12, fontWeight: 600, color: activeTab === t.id ? 'var(--accent)' : 'var(--text-muted)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 10, padding: '3px 8px' }}>
|
||||
{tabCounts[t.id]}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }} ref={companyFilterMenuRef}>
|
||||
@@ -692,9 +795,9 @@ export default function RequestsPage() {
|
||||
{/* Task table */}
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', background: 'var(--card-bg)', borderRadius: 8, border: '1px solid var(--border)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', padding: '18px 21px' }}>
|
||||
{allRows.length === 0 ? (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No tasks yet.</div>
|
||||
<div className="card-empty-center">No tasks</div>
|
||||
) : sortedRows.length === 0 ? (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No tasks.</div>
|
||||
<div className="card-empty-center">No tasks</div>
|
||||
) : (
|
||||
<div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
<table className="table-sticky-head table-no-row-hover" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
|
||||
|
||||
@@ -1,169 +1,401 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import LoadingButton from '../../components/LoadingButton';
|
||||
import SortTh from '../../components/SortTh';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { generateInvoicePDF } from '../../lib/invoice';
|
||||
import { generateInvoicePDF, generateReceiptPDF } from '../../lib/invoice';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { useSortable } from '../../hooks/useSortable';
|
||||
import { popupOverlayStyle, popupSurfaceStyle } from '../../lib/popupStyles';
|
||||
|
||||
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
|
||||
const invoiceStatusLabel = (status) => {
|
||||
if (status === 'sent') return 'Invoiced';
|
||||
return status ? status.charAt(0).toUpperCase() + status.slice(1) : '—';
|
||||
};
|
||||
|
||||
function ClientFinanceStatCard({ label, value, sub, iconBg, iconColor, iconPath }) {
|
||||
return (
|
||||
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', minHeight: 120 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
|
||||
<div style={{ width: 27, height: 27, borderRadius: '50%', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={iconColor} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" dangerouslySetInnerHTML={{ __html: iconPath }} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{value}</div>
|
||||
{sub ? <div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 5 }}>{sub}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientFinanceTooltip({ active, payload, label, year }) {
|
||||
if (!active || !payload?.length) return null;
|
||||
return (
|
||||
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '10px 14px', fontSize: 12 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 6, color: 'var(--text-primary)' }}>{label} {year}</div>
|
||||
{payload.map(p => (
|
||||
<div key={p.dataKey} style={{ color: p.color, marginBottom: 2 }}>{p.name}: <strong>${Number(p.value).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</strong></div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientInvoiceModal({ invoice, onClose }) {
|
||||
const navigate = useNavigate();
|
||||
const [downloading, setDownloading] = useState('');
|
||||
if (!invoice) return null;
|
||||
|
||||
const items = invoice.items || [];
|
||||
const company = invoice.company || null;
|
||||
const isOverdue = invoice.status !== 'paid' && invoice.due_date && new Date(invoice.due_date) < new Date();
|
||||
|
||||
const handleDownloadInvoice = async () => {
|
||||
if (downloading) return;
|
||||
setDownloading('invoice');
|
||||
try {
|
||||
await generateInvoicePDF(invoice, company, items);
|
||||
} finally {
|
||||
setDownloading('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadReceipt = async () => {
|
||||
if (downloading) return;
|
||||
setDownloading('receipt');
|
||||
try {
|
||||
await generateReceiptPDF(invoice, company, items);
|
||||
} finally {
|
||||
setDownloading('');
|
||||
}
|
||||
};
|
||||
|
||||
const openPay = () => {
|
||||
onClose?.();
|
||||
navigate(`/pay/${encodeURIComponent(invoice.invoice_number)}`);
|
||||
};
|
||||
|
||||
const sortedItems = [...items].sort((a, b) => {
|
||||
const aOrder = Number(a.sort_order ?? 0);
|
||||
const bOrder = Number(b.sort_order ?? 0);
|
||||
return aOrder - bOrder;
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={popupOverlayStyle} onClick={onClose}>
|
||||
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', maxWidth: 1180, maxHeight: '86vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16, paddingBottom: 18, marginBottom: 18, borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 28, fontWeight: 500, lineHeight: 1.2 }}>{invoice.invoice_number}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)', marginTop: 4 }}>
|
||||
{company?.id ? (
|
||||
<Link to={`/company/${company.id}`} className="dashboard-inline-link" onClick={onClose}>
|
||||
{company.name}
|
||||
</Link>
|
||||
) : (
|
||||
company?.name || invoice.bill_to || 'Invoice'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-action-row" style={{ alignItems: 'center' }}>
|
||||
<StatusBadge
|
||||
status={statusColor[invoice.status] || 'not_started'}
|
||||
label={`${invoiceStatusLabel(invoice.status)}${isOverdue ? ' · Overdue' : ''}`}
|
||||
/>
|
||||
{invoice.status === 'sent' && (
|
||||
<button className="btn btn-outline" onClick={openPay}>Pay Invoice</button>
|
||||
)}
|
||||
<LoadingButton className="btn btn-outline" loading={downloading === 'invoice'} disabled={Boolean(downloading)} loadingText="Generating…" onClick={handleDownloadInvoice}>
|
||||
Download Invoice
|
||||
</LoadingButton>
|
||||
{invoice.status === 'paid' && (
|
||||
<LoadingButton className="btn btn-outline" loading={downloading === 'receipt'} disabled={Boolean(downloading)} loadingText="Generating…" onClick={handleDownloadReceipt}>
|
||||
Download Receipt
|
||||
</LoadingButton>
|
||||
)}
|
||||
<button className="btn btn-outline" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)', gap: 24, marginBottom: 24, flexShrink: 0 }}>
|
||||
<div className="card" style={{ margin: 0 }}>
|
||||
<div className="card-title">Bill To</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 400 }}>{invoice.bill_to || company?.name || '—'}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 4 }}>
|
||||
{company?.id ? (
|
||||
<Link to={`/company/${company.id}`} className="dashboard-inline-link" onClick={onClose}>
|
||||
{company.name}
|
||||
</Link>
|
||||
) : (
|
||||
company?.name || 'Fourge Branding Client Invoice'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card" style={{ margin: 0 }}>
|
||||
<div className="card-title">Invoice Details</div>
|
||||
<div className="detail-grid" style={{ marginBottom: 0 }}>
|
||||
<div className="detail-item"><label>Invoice Date</label><p>{invoice.invoice_date ? new Date(invoice.invoice_date).toLocaleDateString() : '—'}</p></div>
|
||||
<div className="detail-item"><label>Due Date</label><p style={{ color: isOverdue ? 'var(--danger)' : 'inherit' }}>{invoice.due_date ? new Date(invoice.due_date).toLocaleDateString() : '—'}</p></div>
|
||||
<div className="detail-item"><label>Terms</label><p>Net 30</p></div>
|
||||
<div className="detail-item"><label>Status</label><p><StatusBadge status={statusColor[invoice.status] || 'not_started'} label={invoiceStatusLabel(invoice.status)} /></p></div>
|
||||
<div className="detail-item"><label>Total</label><p style={{ fontSize: 18, fontWeight: 400, color: 'var(--accent)' }}>${Number(invoice.total || 0).toFixed(2)}</p></div>
|
||||
{invoice.paid_at && <div className="detail-item"><label>Paid On</label><p style={{ color: 'var(--success, #16a34a)', fontWeight: 400 }}>{new Date(invoice.paid_at).toLocaleDateString()}</p></div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ margin: 0, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<div className="card-title">Line Items</div>
|
||||
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||
<colgroup>
|
||||
<col style={{ width: '12%' }} />
|
||||
<col style={{ width: '46%' }} />
|
||||
<col style={{ width: '14%' }} />
|
||||
<col style={{ width: '14%' }} />
|
||||
<col style={{ width: '14%' }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Description</th>
|
||||
<th style={{ textAlign: 'center' }}>Qty</th>
|
||||
<th style={{ textAlign: 'right' }}>Unit Price</th>
|
||||
<th style={{ textAlign: 'right' }}>Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedItems.map(item => (
|
||||
<tr key={item.id}>
|
||||
<td style={{ padding: '5px 0' }}>
|
||||
<span className={`badge ${item.submission_id ? 'badge-client_revision' : 'badge-initial'}`}>
|
||||
{item.submission_id ? 'Revision' : 'New'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '5px 0', fontSize: 13 }}>{item.description}</td>
|
||||
<td style={{ padding: '5px 0', fontSize: 13, textAlign: 'center' }}>{item.quantity}</td>
|
||||
<td style={{ padding: '5px 0', fontSize: 13, textAlign: 'right' }}>${Number(item.unit_price || 0).toFixed(2)}</td>
|
||||
<td style={{ padding: '5px 0', fontSize: 13, textAlign: 'right', fontWeight: 400 }}>${(Number(item.quantity || 0) * Number(item.unit_price || 0)).toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', paddingTop: 16, marginTop: 12, borderTop: '1px solid var(--border)', flexShrink: 0 }}>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>Total</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 400, color: 'var(--accent)' }}>${Number(invoice.total || 0).toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{invoice.notes ? (
|
||||
<div className="card" style={{ margin: '24px 0 0 0', flexShrink: 0 }}>
|
||||
<div className="card-title">Notes</div>
|
||||
<p style={{ fontSize: 14, color: 'var(--text-secondary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{invoice.notes}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MyInvoices() {
|
||||
const { currentUser } = useAuth();
|
||||
const companies = (currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : [])).slice().sort((a, b) => a.name.localeCompare(b.name));
|
||||
const [activeCompanyId, setActiveCompanyId] = useState(companies[0]?.id || null);
|
||||
const companyIds = (currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : []))
|
||||
.map(company => company?.id)
|
||||
.filter(Boolean);
|
||||
|
||||
const [invoices, setInvoices] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [generatingInvoiceId, setGeneratingInvoiceId] = useState('');
|
||||
const { sortKey, sortDir, toggle, sort } = useSortable('invoice_date');
|
||||
const [activeInvoice, setActiveInvoice] = useState(null);
|
||||
const [yearFilter, setYearFilter] = useState(String(new Date().getFullYear()));
|
||||
const [companyFilter, setCompanyFilter] = useState('all');
|
||||
const [filterMenuOpen, setFilterMenuOpen] = useState(false);
|
||||
const filterMenuRef = useRef(null);
|
||||
const { sortKey, sortDir, toggle, sort } = useSortable('invoice_date', 'desc');
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
if (companyIds.length === 0) {
|
||||
setInvoices([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const { data } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, company:companies(name), items:invoice_items(*)')
|
||||
.select('*, company:companies(id, name), items:invoice_items(*)')
|
||||
.in('company_id', companyIds)
|
||||
.order('created_at', { ascending: false });
|
||||
setInvoices((data || []).filter(inv => inv.status !== 'draft'));
|
||||
setLoading(false);
|
||||
}
|
||||
load();
|
||||
}, []);
|
||||
}, [currentUser?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleDownload = async (invoice) => {
|
||||
if (generatingInvoiceId) return;
|
||||
setGeneratingInvoiceId(invoice.id);
|
||||
try {
|
||||
await generateInvoicePDF(invoice, invoice.company, invoice.items || []);
|
||||
} finally {
|
||||
setGeneratingInvoiceId('');
|
||||
useEffect(() => {
|
||||
if (!filterMenuOpen) return;
|
||||
function onDocClick(e) {
|
||||
if (filterMenuRef.current && !filterMenuRef.current.contains(e.target)) setFilterMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const visible = companies.length > 1 && activeCompanyId
|
||||
? invoices.filter(inv => inv.company_id === activeCompanyId)
|
||||
: invoices;
|
||||
|
||||
const outstanding = visible.filter(i => i.status === 'sent').reduce((s, i) => s + Number(i.total), 0);
|
||||
const paid = visible.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total), 0);
|
||||
const overdueCount = visible.filter(inv => inv.status !== 'paid' && new Date(inv.due_date) < new Date()).length;
|
||||
document.addEventListener('mousedown', onDocClick);
|
||||
return () => document.removeEventListener('mousedown', onDocClick);
|
||||
}, [filterMenuOpen]);
|
||||
|
||||
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||
const chartYear = new Date().getFullYear();
|
||||
const invoiceYears = [...new Set(invoices.map(i => i.invoice_date?.slice(0, 4)).filter(Boolean))].sort((a, b) => b.localeCompare(a));
|
||||
const availableCompanyOptions = (currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : []))
|
||||
.filter(company => company?.id && invoices.some(inv => inv.company_id === company.id))
|
||||
.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||
const chartYear = Number(yearFilter) || new Date().getFullYear();
|
||||
const filteredInvoices = invoices.filter(inv => {
|
||||
if (yearFilter !== 'all' && inv.invoice_date?.slice(0, 4) !== yearFilter) return false;
|
||||
if (companyFilter !== 'all' && inv.company_id !== companyFilter) return false;
|
||||
return true;
|
||||
});
|
||||
const chartData = useMemo(() => MONTHS.map((month, mi) => {
|
||||
const paidAmt = visible.filter(i => i.status === 'paid' && new Date(i.invoice_date).getFullYear() === chartYear && new Date(i.invoice_date).getMonth() === mi).reduce((s, i) => s + Number(i.total || 0), 0);
|
||||
const outAmt = visible.filter(i => i.status === 'sent' && new Date(i.invoice_date).getFullYear() === chartYear && new Date(i.invoice_date).getMonth() === mi).reduce((s, i) => s + Number(i.total || 0), 0);
|
||||
const paidAmt = filteredInvoices.filter(i => i.status === 'paid' && new Date(i.invoice_date).getFullYear() === chartYear && new Date(i.invoice_date).getMonth() === mi).reduce((s, i) => s + Number(i.total || 0), 0);
|
||||
const outAmt = filteredInvoices.filter(i => i.status === 'sent' && new Date(i.invoice_date).getFullYear() === chartYear && new Date(i.invoice_date).getMonth() === mi).reduce((s, i) => s + Number(i.total || 0), 0);
|
||||
return { month, Paid: +paidAmt.toFixed(2), Outstanding: +outAmt.toFixed(2) };
|
||||
}), [visible, chartYear]);
|
||||
const hasChartData = chartData.some(d => d.Paid > 0 || d.Outstanding > 0);
|
||||
}), [filteredInvoices, chartYear]);
|
||||
|
||||
const sorted = sort(visible, (inv, key) => {
|
||||
const outstanding = filteredInvoices.filter(i => i.status === 'sent').reduce((s, i) => s + Number(i.total || 0), 0);
|
||||
const paid = filteredInvoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total || 0), 0);
|
||||
const overdueCount = filteredInvoices.filter(inv => inv.status !== 'paid' && inv.due_date && new Date(inv.due_date) < new Date()).length;
|
||||
const paidCount = filteredInvoices.filter(i => i.status === 'paid').length;
|
||||
|
||||
const sortedInvoices = sort(filteredInvoices, (inv, key) => {
|
||||
if (key === 'invoice_date' || key === 'due_date') return new Date(inv[key] || 0).getTime();
|
||||
if (key === 'total') return Number(inv.total || 0);
|
||||
if (key === 'company') return inv.company?.name || '';
|
||||
return inv[key] || '';
|
||||
});
|
||||
|
||||
const th = { sortKey, sortDir, onSort: toggle };
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">Invoices</div>
|
||||
<div className="page-subtitle">{visible.length} invoice{visible.length !== 1 ? 's' : ''}</div>
|
||||
<Layout loading={loading}>
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '3fr 2fr', gap: 24, flexShrink: 0 }}>
|
||||
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Invoice Overview</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{chartYear}</span>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="clientPaidGrad" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#4ade80" stopOpacity={0.25}/><stop offset="95%" stopColor="#4ade80" stopOpacity={0}/></linearGradient>
|
||||
<linearGradient id="clientOutGrad" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#60a5fa" stopOpacity={0.2}/><stop offset="95%" stopColor="#60a5fa" stopOpacity={0}/></linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} />
|
||||
<XAxis dataKey="month" tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} tickFormatter={v => `$${v >= 1000 ? (v / 1000).toFixed(0) + 'k' : v}`} width={45} />
|
||||
<Tooltip content={<ClientFinanceTooltip year={chartYear} />} />
|
||||
<Legend wrapperStyle={{ fontSize: 11, paddingTop: 8 }} />
|
||||
<Area type="monotone" dataKey="Paid" stroke="#4ade80" strokeWidth={2} fill="url(#clientPaidGrad)" dot={false} activeDot={{ r: 4 }} />
|
||||
<Area type="monotone" dataKey="Outstanding" stroke="#60a5fa" strokeWidth={2} fill="url(#clientOutGrad)" dot={false} activeDot={{ r: 4 }} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, alignContent: 'start' }}>
|
||||
<ClientFinanceStatCard label="Paid" value={`$${paid.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`} sub={`${paidCount} settled`} iconBg="rgba(74,222,128,0.15)" iconColor="#4ade80" iconPath={'<polyline points="4,12 9,17 20,6"/>'} />
|
||||
<ClientFinanceStatCard label="Outstanding" value={`$${outstanding.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`} sub="ready to pay" iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={'<circle cx="12" cy="12" r="8"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>'} />
|
||||
<ClientFinanceStatCard label="Overdue" value={overdueCount} sub={overdueCount === 1 ? 'needs attention' : 'need attention'} iconBg="rgba(239,68,68,0.15)" iconColor="#ef4444" iconPath={'<path d="M12 9v4"/><path d="M12 16h.01"/><path d="M10.29 3.86l-7.5 13A1 1 0 0 0 3.66 18h16.68a1 1 0 0 0 .87-1.5l-7.5-13a1 1 0 0 0-1.74 0z"/>'} />
|
||||
<ClientFinanceStatCard label="Invoices" value={invoices.length} sub={`${companyIds.length} companies`} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconPath={'<rect x="3" y="4" width="18" height="16" rx="2"/><line x1="7" y1="9" x2="17" y2="9"/><line x1="7" y1="13" x2="17" y2="13"/>'} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 24, marginBottom: 10, flexShrink: 0, minHeight: 'var(--btn-height)' }}>
|
||||
<div style={{ marginLeft: 'auto', position: 'relative', display: 'flex', alignItems: 'center' }} ref={filterMenuRef}>
|
||||
<button className="btn btn-outline" onClick={() => setFilterMenuOpen(o => !o)} style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }} title="Filter invoices">
|
||||
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M2 4h12M4 8h8M6 12h4" /></svg>
|
||||
</button>
|
||||
{filterMenuOpen && (
|
||||
<div className="site-header-avatar-menu" style={{ position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 220, minWidth: 160 }}>
|
||||
<div style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-muted)', padding: '6px 14px 4px' }}>Year</div>
|
||||
<button onClick={() => { setYearFilter('all'); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: yearFilter === 'all' ? 'rgba(245,165,35,0.08)' : 'transparent', color: yearFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Years</button>
|
||||
{invoiceYears.map(year => (
|
||||
<button key={year} onClick={() => { setYearFilter(year); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: yearFilter === year ? 'rgba(245,165,35,0.08)' : 'transparent', color: yearFilter === year ? 'var(--accent)' : 'var(--text-primary)' }}>{year}</button>
|
||||
))}
|
||||
{availableCompanyOptions.length > 1 && (
|
||||
<>
|
||||
<div style={{ borderTop: '1px solid var(--border)', margin: '4px 0' }} />
|
||||
<div style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-muted)', padding: '4px 14px 4px' }}>Company</div>
|
||||
<button onClick={() => { setCompanyFilter('all'); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: companyFilter === 'all' ? 'rgba(245,165,35,0.08)' : 'transparent', color: companyFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Companies</button>
|
||||
{availableCompanyOptions.map(company => (
|
||||
<button key={company.id} onClick={() => { setCompanyFilter(company.id); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: companyFilter === company.id ? 'rgba(245,165,35,0.08)' : 'transparent', color: companyFilter === company.id ? 'var(--accent)' : 'var(--text-primary)' }}>{company.name}</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
|
||||
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
{filteredInvoices.length === 0 ? (
|
||||
<div className="card-empty-center">No invoices</div>
|
||||
) : (
|
||||
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
<table className="table-sticky-head table-no-row-hover" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||
<colgroup>
|
||||
<col style={{ width: '15%' }} />
|
||||
<col style={{ width: '20%' }} />
|
||||
<col style={{ width: '10%' }} />
|
||||
<col style={{ width: '10%' }} />
|
||||
<col style={{ width: '10%' }} />
|
||||
<col style={{ width: '15%' }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="invoice_number" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Invoice #</SortTh>
|
||||
<SortTh col="company" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Company</SortTh>
|
||||
<SortTh col="invoice_date" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Issued</SortTh>
|
||||
<SortTh col="due_date" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Due</SortTh>
|
||||
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Status</SortTh>
|
||||
<SortTh col="total" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Total</SortTh>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedInvoices.map(inv => {
|
||||
const isOverdue = inv.status !== 'paid' && inv.due_date && new Date(inv.due_date) < new Date();
|
||||
return (
|
||||
<tr key={inv.id}>
|
||||
<td style={{ padding: '5px 0' }}>
|
||||
<button type="button" className="table-link" onClick={() => setActiveInvoice(inv)}>{inv.invoice_number}</button>
|
||||
</td>
|
||||
<td style={{ padding: '5px 0', fontSize: 13 }}>
|
||||
{inv.company?.id ? (
|
||||
<Link to={`/company/${inv.company.id}`} className="table-link">
|
||||
{inv.company?.name || inv.bill_to || '—'}
|
||||
</Link>
|
||||
) : (
|
||||
inv.company?.name || inv.bill_to || '—'
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '5px 0', fontSize: 12, color: 'var(--text-muted)' }}>{inv.invoice_date ? new Date(inv.invoice_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</td>
|
||||
<td style={{ padding: '5px 0', fontSize: 12, color: isOverdue ? 'var(--danger)' : 'var(--text-muted)' }}>{inv.due_date ? new Date(inv.due_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</td>
|
||||
<td style={{ padding: '5px 0' }}>
|
||||
<StatusBadge status={statusColor[inv.status] || 'not_started'} label={invoiceStatusLabel(inv.status)} />
|
||||
</td>
|
||||
<td style={{ padding: '5px 0', textAlign: 'right', fontSize: 13, color: inv.status === 'paid' ? '#4ade80' : inv.status === 'sent' ? '#F5A523' : 'var(--text-primary)', fontWeight: 400 }}>${Number(inv.total || 0).toFixed(2)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{hasChartData && (
|
||||
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '16px 16px 8px', marginBottom: 18 }}>
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="gradPaidC" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#4ade80" stopOpacity={0.25}/><stop offset="95%" stopColor="#4ade80" stopOpacity={0}/></linearGradient>
|
||||
<linearGradient id="gradOutC" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#60a5fa" stopOpacity={0.2}/><stop offset="95%" stopColor="#60a5fa" stopOpacity={0}/></linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} />
|
||||
<XAxis dataKey="month" tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} tickFormatter={v => `$${v >= 1000 ? (v/1000).toFixed(0)+'k' : v}`} width={45} />
|
||||
<Tooltip formatter={(v) => [`$${Number(v).toLocaleString('en-US', { minimumFractionDigits: 2 })}`, undefined]} contentStyle={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, fontSize: 12 }} />
|
||||
<Legend wrapperStyle={{ fontSize: 11, paddingTop: 8 }} />
|
||||
<Area type="monotone" dataKey="Paid" stroke="#4ade80" strokeWidth={2} fill="url(#gradPaidC)" dot={false} activeDot={{ r: 4 }} />
|
||||
<Area type="monotone" dataKey="Outstanding" stroke="#60a5fa" strokeWidth={2} fill="url(#gradOutC)" dot={false} activeDot={{ r: 4 }} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{companies.length > 1 && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<select className="filter-select" value={activeCompanyId || ''} onChange={e => setActiveCompanyId(e.target.value)}>
|
||||
{companies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<p style={{ color: 'var(--text-muted)' }}>Loading...</p>
|
||||
) : visible.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>No invoices yet</h3>
|
||||
<p>Your invoices will appear here once they are sent.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card">
|
||||
<div className="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="invoice_number" {...th}>Invoice #</SortTh>
|
||||
<SortTh col="invoice_date" {...th}>Issued</SortTh>
|
||||
<SortTh col="due_date" {...th}>Due</SortTh>
|
||||
<SortTh col="status" {...th}>Status</SortTh>
|
||||
<SortTh col="total" {...th}>Total</SortTh>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map(inv => {
|
||||
const isOverdue = inv.status !== 'paid' && new Date(inv.due_date) < new Date();
|
||||
return (
|
||||
<tr key={inv.id}>
|
||||
<td style={{ fontWeight: 400 }}>{inv.invoice_number}</td>
|
||||
<td style={{ color: 'var(--text-muted)' }}>{new Date(inv.invoice_date).toLocaleDateString()}</td>
|
||||
<td style={{ color: isOverdue ? 'var(--danger)' : 'var(--text-muted)' }}>
|
||||
{inv.due_date ? new Date(inv.due_date).toLocaleDateString() : '—'}
|
||||
{isOverdue && <span style={{ marginLeft: 6, fontSize: 11 }}>Overdue</span>}
|
||||
</td>
|
||||
<td>
|
||||
<StatusBadge
|
||||
status={statusColor[inv.status] || 'not_started'}
|
||||
label={inv.status ? inv.status.charAt(0).toUpperCase() + inv.status.slice(1) : '—'}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ fontWeight: 400, color: 'var(--accent)' }}>${Number(inv.total).toFixed(2)}</td>
|
||||
<td>
|
||||
<LoadingButton
|
||||
className="btn btn-outline btn-sm"
|
||||
loading={generatingInvoiceId === inv.id}
|
||||
disabled={Boolean(generatingInvoiceId)}
|
||||
loadingText="Generating..."
|
||||
onClick={() => handleDownload(inv)}
|
||||
>
|
||||
Download PDF
|
||||
</LoadingButton>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ClientInvoiceModal invoice={activeInvoice} onClose={() => setActiveInvoice(null)} />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
+2
-288
@@ -1,296 +1,10 @@
|
||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import LoadingButton from '../../components/LoadingButton';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { sendEmail } from '../../lib/email';
|
||||
|
||||
const INVOICE_TODAY = new Date().toISOString().split('T')[0];
|
||||
|
||||
function newItem(description = '', unit_price = '', quantity = 1, taskId = null, isRevision = false) {
|
||||
return { id: crypto.randomUUID(), description, unit_price, quantity, task_id: taskId, is_revision: isRevision };
|
||||
}
|
||||
|
||||
function genNumber(count) {
|
||||
const year = new Date().getFullYear();
|
||||
return `INVSUB-${year}-${String(count + 1).padStart(3, '0')}`;
|
||||
}
|
||||
import SubcontractorInvoiceForm from '../../components/SubcontractorInvoiceForm';
|
||||
|
||||
export default function MyInvoiceCreate() {
|
||||
const navigate = useNavigate();
|
||||
const { currentUser } = useAuth();
|
||||
|
||||
const [invoiceNumber, setInvoiceNumber] = useState('');
|
||||
const [completedTasks, setCompletedTasks] = useState([]);
|
||||
const [loadingTasks, setLoadingTasks] = useState(true);
|
||||
const [items, setItems] = useState([newItem()]);
|
||||
const [notes, setNotes] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const dragItem = useRef(null);
|
||||
|
||||
const rate = Number(currentUser?.brand_book_rate || 60);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const [{ data: tasks }, { data: existingInvoices }, { data: nextNum }] = await Promise.all([
|
||||
supabase
|
||||
.from('tasks')
|
||||
.select('id, title, current_version, project:projects(name)')
|
||||
.eq('status', 'client_approved')
|
||||
.eq('assigned_to', currentUser.id)
|
||||
.order('title'),
|
||||
supabase
|
||||
.from('subcontractor_invoices')
|
||||
.select('id, status, items:subcontractor_invoice_items(task_id)')
|
||||
.in('status', ['submitted', 'paid']),
|
||||
supabase.rpc('get_next_sub_invoice_number'),
|
||||
]);
|
||||
|
||||
setInvoiceNumber(nextNum || genNumber(0));
|
||||
|
||||
const invoicedTaskIds = new Set(
|
||||
(existingInvoices || []).flatMap(inv => (inv.items || []).map(i => i.task_id).filter(Boolean))
|
||||
);
|
||||
setCompletedTasks((tasks || []).filter(t => !invoicedTaskIds.has(t.id)));
|
||||
setLoadingTasks(false);
|
||||
}
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const addedTaskIds = useMemo(() => new Set(items.map(i => i.task_id).filter(Boolean)), [items]);
|
||||
|
||||
const addTask = (task) => {
|
||||
if (addedTaskIds.has(task.id)) return;
|
||||
const version = task.current_version || 0;
|
||||
const baseDesc = task.project?.name ? `${task.project.name} • ${task.title}` : task.title;
|
||||
const toAdd = [newItem(`${baseDesc} – R00`, rate, 1, task.id, false)];
|
||||
for (let v = 1; v <= version; v++) {
|
||||
toAdd.push(newItem(`${baseDesc} – R${String(v).padStart(2, '0')}`, 30, 1, task.id, true));
|
||||
}
|
||||
setItems(prev => {
|
||||
if (prev.length === 1 && !prev[0].description && !prev[0].unit_price) return toAdd;
|
||||
return [...prev, ...toAdd];
|
||||
});
|
||||
};
|
||||
|
||||
const updateItem = (id, field, value) => {
|
||||
setItems(prev => prev.map(item => item.id === id ? { ...item, [field]: value } : item));
|
||||
};
|
||||
|
||||
const removeItem = (id) => setItems(prev => prev.filter(item => item.id !== id));
|
||||
|
||||
const handleDrop = (targetIndex) => {
|
||||
if (dragItem.current === null || dragItem.current === targetIndex) { dragItem.current = null; return; }
|
||||
setItems(prev => {
|
||||
const next = [...prev];
|
||||
const [moved] = next.splice(dragItem.current, 1);
|
||||
next.splice(targetIndex, 0, moved);
|
||||
return next;
|
||||
});
|
||||
dragItem.current = null;
|
||||
};
|
||||
|
||||
const total = items.reduce((s, i) => s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0), 0);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const valid = items.filter(i => String(i.description).trim());
|
||||
if (!valid.length) { setError('Add at least one line item.'); return; }
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const { data: inv, error: invErr } = await supabase
|
||||
.from('subcontractor_invoices')
|
||||
.insert({
|
||||
profile_id: currentUser.id,
|
||||
invoice_number: invoiceNumber.trim(),
|
||||
status: 'submitted',
|
||||
notes: notes.trim(),
|
||||
submitted_at: new Date().toISOString(),
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (invErr) throw invErr;
|
||||
|
||||
const { error: itemsErr } = await supabase.from('subcontractor_invoice_items').insert(
|
||||
valid.map((item, idx) => ({
|
||||
invoice_id: inv.id,
|
||||
task_id: item.task_id || null,
|
||||
description: String(item.description).trim(),
|
||||
quantity: Number(item.quantity) || 1,
|
||||
unit_price: Number(item.unit_price) || 0,
|
||||
sort_order: idx,
|
||||
}))
|
||||
);
|
||||
if (itemsErr) throw itemsErr;
|
||||
|
||||
const total = valid.reduce((s, i) => s + (Number(i.unit_price) || 0) * (Number(i.quantity) || 1), 0);
|
||||
sendEmail('subcontractor_invoice_submitted', 'hello@fourgebranding.com', {
|
||||
subName: currentUser.name,
|
||||
invoiceNumber: inv.invoice_number,
|
||||
total: total.toFixed(2),
|
||||
invoiceId: inv.id,
|
||||
}).catch(err => console.error('Sub invoice notification failed:', err));
|
||||
|
||||
navigate(`/my-invoices-sub/${inv.id}`);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<button className="back-link" onClick={() => navigate('/my-invoices-sub')}>← Back to Invoices</button>
|
||||
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">New Invoice</div>
|
||||
<div className="page-subtitle">
|
||||
Invoice date: {new Date(INVOICE_TODAY).toLocaleDateString()} · {invoiceNumber}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="notification notification-info" style={{ marginBottom: 16 }}>{error}</div>}
|
||||
|
||||
<div className="card" style={{ marginBottom: 24 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<div className="card-title" style={{ marginBottom: 0 }}>Completed Tasks</div>
|
||||
{completedTasks.length > 0 && !loadingTasks && (
|
||||
<button
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={() => completedTasks.forEach(task => { if (!addedTaskIds.has(task.id)) addTask(task); })}
|
||||
>
|
||||
+ Add All
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{loadingTasks ? (
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading...</p>
|
||||
) : completedTasks.length === 0 ? (
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>No completed tasks available to invoice.</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{completedTasks.map(task => {
|
||||
const alreadyAdded = addedTaskIds.has(task.id);
|
||||
return (
|
||||
<div key={task.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 400 }}>
|
||||
{task.project?.name ? `${task.project.name} • ` : ''}{task.title}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
{(() => {
|
||||
const v = task.current_version || 0;
|
||||
const parts = [`R00 New Book $${rate.toFixed(2)}`];
|
||||
if (v > 0) parts.push(`+ ${v} Revision${v > 1 ? 's' : ''} @ $30ea`);
|
||||
return parts.join(' · ');
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className={`btn btn-sm ${alreadyAdded ? 'btn-outline' : 'btn-primary'}`}
|
||||
onClick={() => !alreadyAdded && addTask(task)}
|
||||
disabled={alreadyAdded}
|
||||
>
|
||||
{alreadyAdded ? 'Added' : '+ Add'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginBottom: 24 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<div className="card-title" style={{ marginBottom: 0 }}>Line Items</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 80px 120px 120px 40px', gap: 8, marginBottom: 8 }}>
|
||||
{['', 'Type', 'Description', 'Qty / Hrs', 'Rate', 'Total', ''].map((h, i) => (
|
||||
<div key={i} style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', textAlign: i > 3 ? 'right' : 'left' }}>{h}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
onDragOver={e => e.preventDefault()}
|
||||
onDrop={() => handleDrop(index)}
|
||||
style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 80px 120px 120px 40px', gap: 8, alignItems: 'center' }}
|
||||
>
|
||||
<div
|
||||
draggable
|
||||
onDragStart={e => { dragItem.current = index; e.dataTransfer.effectAllowed = 'move'; }}
|
||||
style={{ cursor: 'grab', color: 'var(--text-muted)', fontSize: 14, textAlign: 'center', userSelect: 'none' }}
|
||||
>⠿</div>
|
||||
<span className={`badge ${item.is_revision ? 'badge-client_revision' : 'badge-initial'}`}>
|
||||
{item.is_revision ? 'Revision' : 'New'}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Description..."
|
||||
value={item.description}
|
||||
onChange={e => updateItem(item.id, 'description', e.target.value)}
|
||||
style={{ margin: 0 }}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min="0.5"
|
||||
step="0.5"
|
||||
value={item.quantity}
|
||||
onChange={e => updateItem(item.id, 'quantity', e.target.value)}
|
||||
style={{ margin: 0, textAlign: 'center' }}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
value={item.unit_price}
|
||||
onChange={e => updateItem(item.id, 'unit_price', e.target.value)}
|
||||
style={{ margin: 0, textAlign: 'right' }}
|
||||
/>
|
||||
<div style={{ textAlign: 'right', fontSize: 14, fontWeight: 400, color: 'var(--text-primary)', paddingRight: 4 }}>
|
||||
${((Number(item.quantity) || 0) * (Number(item.unit_price) || 0)).toFixed(2)}
|
||||
</div>
|
||||
<button onClick={() => removeItem(item.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--danger)', fontSize: 16, padding: 4 }}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button className="btn btn-outline btn-sm" style={{ marginTop: 12 }} onClick={() => setItems(prev => [...prev, newItem()])}>
|
||||
+ Add Line Item
|
||||
</button>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 20, paddingTop: 16, borderTop: '1px solid var(--border)' }}>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>Total</div>
|
||||
<div style={{ fontSize: 26, fontWeight: 400, color: 'var(--accent)' }}>${total.toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginBottom: 24 }}>
|
||||
<div className="card-title">Notes</div>
|
||||
<textarea
|
||||
placeholder="Additional notes for the team..."
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
style={{ minHeight: 80 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="action-buttons">
|
||||
<LoadingButton className="btn btn-primary" onClick={handleSubmit} loading={saving} loadingText="Submitting...">
|
||||
Submit Invoice
|
||||
</LoadingButton>
|
||||
<button className="btn btn-outline" onClick={() => navigate('/my-invoices-sub')} disabled={saving}>Cancel</button>
|
||||
</div>
|
||||
<SubcontractorInvoiceForm />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
+64
-186
@@ -1,34 +1,27 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import PageLoader from '../../components/PageLoader';
|
||||
import LoadingButton from '../../components/LoadingButton';
|
||||
import SortTh from '../../components/SortTh';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { generateSubcontractorPOPDF } from '../../lib/invoice';
|
||||
import { useSortable } from '../../hooks/useSortable';
|
||||
import SubcontractorInvoiceDetailView from '../../components/SubcontractorInvoiceDetailView';
|
||||
|
||||
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);
|
||||
return (items || []).reduce((sum, item) => sum + Number(item.unit_price || 0) * Number(item.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('');
|
||||
const { sortKey, sortDir, toggle, sort } = useSortable('description');
|
||||
@@ -40,7 +33,11 @@ export default function MyInvoiceDetail() {
|
||||
.select('*, items:subcontractor_invoice_items(*)')
|
||||
.eq('id', id)
|
||||
.single();
|
||||
if (err || !data) { setError('Invoice not found.'); setLoading(false); return; }
|
||||
if (err || !data) {
|
||||
setError('Invoice not found.');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setInvoice(data);
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -49,24 +46,18 @@ export default function MyInvoiceDetail() {
|
||||
|
||||
async function handleSubmit() {
|
||||
setSubmitting(true);
|
||||
const submittedAt = new Date().toISOString();
|
||||
const { error: err } = await supabase
|
||||
.from('subcontractor_invoices')
|
||||
.update({ status: 'submitted', submitted_at: new Date().toISOString() })
|
||||
.update({ status: 'submitted', submitted_at: submittedAt })
|
||||
.eq('id', id);
|
||||
if (err) setError(err.message);
|
||||
else setInvoice(i => ({ ...i, status: 'submitted', submitted_at: new Date().toISOString() }));
|
||||
else setInvoice((current) => ({ ...current, status: 'submitted', submitted_at: submittedAt }));
|
||||
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() {
|
||||
if (!invoice) return;
|
||||
setDownloading(true);
|
||||
try {
|
||||
const total = invoiceTotal(invoice.items);
|
||||
@@ -92,13 +83,16 @@ export default function MyInvoiceDetail() {
|
||||
setDownloading(false);
|
||||
}
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
if (loading) return <Layout><PageLoader /></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));
|
||||
const tableItems = sort(sortedItems, (item, key) => {
|
||||
if (key === 'type') return item.task_id ? 'Task' : 'Other';
|
||||
const typeSort = /\bR(\d{2})\b/i.test(String(item.description || ''))
|
||||
? Number(String(item.description).match(/\bR(\d{2})\b/i)?.[1] || 0)
|
||||
: item.task_id ? 0 : 999;
|
||||
if (key === 'type') return typeSort;
|
||||
if (key === 'description') return item.description || '';
|
||||
if (key === 'quantity') return Number(item.quantity || 0);
|
||||
if (key === 'unit_price') return Number(item.unit_price || 0);
|
||||
@@ -106,175 +100,59 @@ export default function MyInvoiceDetail() {
|
||||
return '';
|
||||
});
|
||||
|
||||
const headerActions = (
|
||||
<>
|
||||
{invoice.status === 'draft' ? (
|
||||
<LoadingButton className="btn btn-outline" loading={submitting} loadingText="Submitting…" disabled={submitting} onClick={handleSubmit}>
|
||||
Submit to Team
|
||||
</LoadingButton>
|
||||
) : null}
|
||||
{invoice.status === 'paid' ? (
|
||||
<LoadingButton className="btn btn-outline" loading={downloading} loadingText="Generating…" disabled={downloading} onClick={handleDownload}>
|
||||
Download Receipt
|
||||
</LoadingButton>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
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">
|
||||
<StatusBadge
|
||||
status={STATUS_COLOR[invoice.status] || 'not_started'}
|
||||
label={STATUS_LABEL[invoice.status] || invoice.status}
|
||||
/>
|
||||
{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: 400 }}>{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: 400 }}>{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>
|
||||
<StatusBadge
|
||||
status={STATUS_COLOR[invoice.status] || 'not_started'}
|
||||
label={STATUS_LABEL[invoice.status] || invoice.status}
|
||||
/>
|
||||
</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: 400, color: 'var(--accent)' }}>{fmt(total)}</p>
|
||||
</div>
|
||||
{invoice.paid_at && (
|
||||
<div className="detail-item">
|
||||
<SubcontractorInvoiceDetailView
|
||||
error={error}
|
||||
headerActions={headerActions}
|
||||
leftCardTitle="From"
|
||||
leftCardBody={(
|
||||
<>
|
||||
<div style={{ fontSize: 15, fontWeight: 400 }}>{currentUser?.name || 'Subcontractor'}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 4 }}>{currentUser?.email || '—'}</div>
|
||||
</>
|
||||
)}
|
||||
rightCardTitle="Invoice Details"
|
||||
rightCardBody={(
|
||||
<div className="invoice-detail-meta-grid">
|
||||
<div className="invoice-detail-meta-item"><label>Invoice #</label><p>{invoice.invoice_number}</p></div>
|
||||
<div className="invoice-detail-meta-item"><label>Created</label><p>{new Date(invoice.created_at).toLocaleDateString()}</p></div>
|
||||
<div className="invoice-detail-meta-item"><label>Terms</label><p>NET 30 from client payment</p></div>
|
||||
<div className="invoice-detail-meta-item"><label>Status</label><p>{STATUS_LABEL[invoice.status] || invoice.status}</p></div>
|
||||
{invoice.submitted_at ? <div className="invoice-detail-meta-item"><label>Submitted</label><p>{new Date(invoice.submitted_at).toLocaleDateString()}</p></div> : null}
|
||||
<div className="invoice-detail-meta-item"><label>Total</label><p style={{ fontSize: 18, color: 'var(--accent)' }}>${total.toFixed(2)}</p></div>
|
||||
{invoice.paid_at ? (
|
||||
<div className="invoice-detail-meta-item">
|
||||
<label>Paid On</label>
|
||||
<p style={{ color: 'var(--success, #16a34a)', fontWeight: 400 }}>
|
||||
<p style={{ color: 'var(--success, #16a34a)' }}>
|
||||
{new Date(invoice.paid_at).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</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>
|
||||
<SortTh col="type" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ width: 100 }}>Type</SortTh>
|
||||
<SortTh col="description" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Description</SortTh>
|
||||
<SortTh col="quantity" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'center' }}>Qty / Hrs</SortTh>
|
||||
<SortTh col="unit_price" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Rate</SortTh>
|
||||
<SortTh col="line_total" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Total</SortTh>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tableItems.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: 400 }}>
|
||||
{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: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>Total</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 400, 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-icon btn-icon-danger"
|
||||
loading={deleting}
|
||||
loadingText="..."
|
||||
disabled={submitting || deleting}
|
||||
title="Delete Invoice"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
✕
|
||||
</LoadingButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
sortKey={sortKey}
|
||||
sortDir={sortDir}
|
||||
onSort={toggle}
|
||||
items={tableItems}
|
||||
notes={invoice.notes}
|
||||
total={total}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
+524
-99
@@ -1,16 +1,27 @@
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import SortTh from '../../components/SortTh';
|
||||
import LoadingButton from '../../components/LoadingButton';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
||||
import { useSortable } from '../../hooks/useSortable';
|
||||
import { isCompletedVersionEligible } from '../../lib/invoiceVersionRules';
|
||||
import { popupOverlayStyle, popupSurfaceStyle } from '../../lib/popupStyles';
|
||||
import SubcontractorInvoiceForm from '../../components/SubcontractorInvoiceForm';
|
||||
import { generateSubcontractorPOPDF } from '../../lib/invoice';
|
||||
|
||||
const STATUS_BADGE = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
|
||||
const STATUS_LABEL = { draft: 'Draft', submitted: 'Submitted', paid: 'Paid' };
|
||||
const FIELD_LABEL_STYLE = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, display: 'block', marginBottom: 4 };
|
||||
|
||||
const STAT_ICONS = {
|
||||
newTasks: '<rect x="3" y="4" width="18" height="16" rx="2"/><line x1="7" y1="9" x2="17" y2="9"/><line x1="7" y1="13" x2="17" y2="13"/>',
|
||||
revisions: '<path d="M4 12a8 8 0 018-8v0a8 8 0 018 8"/><polyline points="18,8 20,12 16,12"/>',
|
||||
invoiced: '<path d="M5 3h11l3 3v15H5z"/><path d="M16 3v4h4"/><line x1="8" y1="11" x2="16" y2="11"/><line x1="8" y1="15" x2="16" y2="15"/>',
|
||||
paid: '<polyline points="4,12 9,17 20,6"/>',
|
||||
};
|
||||
|
||||
function fmt(val) {
|
||||
return `$${Number(val || 0).toFixed(2)}`;
|
||||
@@ -20,121 +31,535 @@ function invoiceTotal(items) {
|
||||
return (items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
|
||||
}
|
||||
|
||||
function TaskStatCard({ label, value, sub, iconBg, iconColor, iconPath }) {
|
||||
return (
|
||||
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', minHeight: 120 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
|
||||
<div style={{ width: 27, height: 27, borderRadius: '50%', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={iconColor} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" dangerouslySetInnerHTML={{ __html: iconPath }} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{value}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 5 }}>{sub}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function asArray(value) {
|
||||
if (Array.isArray(value)) return value;
|
||||
if (value == null) return [];
|
||||
return typeof value === 'object' ? [value] : [];
|
||||
}
|
||||
|
||||
function parseItemVersionNumber(item) {
|
||||
if (Number.isFinite(Number(item?.version_number))) return Number(item.version_number);
|
||||
const match = String(item?.description || '').match(/\bR(\d{2})\b/i);
|
||||
return match ? Number(match[1]) : 0;
|
||||
}
|
||||
|
||||
function itemVersionLabel(item) {
|
||||
return `R${String(parseItemVersionNumber(item)).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function itemWorkLabel(item) {
|
||||
return parseItemVersionNumber(item) > 0 ? 'Revision' : 'New';
|
||||
}
|
||||
|
||||
function cleanItemDescription(item) {
|
||||
return String(item?.description || '—').replace(/\s*[–-]\s*R\d{2}\b/i, '').trim() || '—';
|
||||
}
|
||||
|
||||
async function fetchTaskTypeMap(taskIds) {
|
||||
const uniqueTaskIds = [...new Set((taskIds || []).filter(Boolean))];
|
||||
if (uniqueTaskIds.length === 0) return {};
|
||||
const { data, error } = await supabase
|
||||
.from('tasks')
|
||||
.select('id, title, submissions(service_type, type)')
|
||||
.in('id', uniqueTaskIds);
|
||||
if (error) throw error;
|
||||
const next = {};
|
||||
for (const task of (data || [])) {
|
||||
const submissions = asArray(task.submissions);
|
||||
const initial = submissions.find((submission) => submission?.type === 'initial' && submission?.service_type);
|
||||
const fallback = submissions.find((submission) => submission?.service_type);
|
||||
next[task.id] = initial?.service_type || fallback?.service_type || task.title || 'Other';
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function buildPDFArgs(invoice, profile, total, statusOverride) {
|
||||
return {
|
||||
po_number: invoice.invoice_number,
|
||||
status: statusOverride || invoice.status || 'draft',
|
||||
profile,
|
||||
project: { name: 'Subcontractor Invoice', company: { name: 'Fourge Branding' } },
|
||||
date: invoice.created_at?.split('T')[0],
|
||||
due_date: invoice.paid_at?.split('T')[0] || invoice.submitted_at?.split('T')[0] || invoice.created_at?.split('T')[0],
|
||||
amount: total,
|
||||
description: invoice.status === 'paid'
|
||||
? 'Payment received for completed subcontractor work.'
|
||||
: 'Subcontractor invoice for completed 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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
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 [invoices, setInvoices] = useState(() => cached?.invoices || []);
|
||||
const [stats, setStats] = useState(() => cached?.stats || { completedNewTasks: 0, completedRevisions: 0, invoicedUnits: 0, paidUnits: 0 });
|
||||
const [loading, setLoading] = useState(() => !cached);
|
||||
const [error, setError] = useState('');
|
||||
const { sortKey, sortDir, toggle, sort } = useSortable('created_at');
|
||||
const [showInvoiceForm, setShowInvoiceForm] = useState(false);
|
||||
const { sortKey, sortDir, toggle, sort } = useSortable('created_at', 'desc');
|
||||
const { sortKey: detailSortKey, sortDir: detailSortDir, toggle: toggleDetailSort, sort: sortDetailItems } = useSortable('description');
|
||||
const [viewingInvoice, setViewingInvoice] = useState(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [detailError, setDetailError] = useState('');
|
||||
const [detailTaskTypeMap, setDetailTaskTypeMap] = useState({});
|
||||
const [submittingInvoice, setSubmittingInvoice] = useState(false);
|
||||
const [downloadingInvoice, setDownloadingInvoice] = useState(false);
|
||||
const [downloadingReceipt, setDownloadingReceipt] = useState(false);
|
||||
|
||||
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 || []); }
|
||||
async function load() {
|
||||
if (!currentUser?.id) {
|
||||
setInvoices([]);
|
||||
setStats({ completedNewTasks: 0, completedRevisions: 0, invoicedUnits: 0, paidUnits: 0 });
|
||||
setLoading(false);
|
||||
});
|
||||
}, [currentUser?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
return;
|
||||
}
|
||||
|
||||
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||
const chartYear = new Date().getFullYear();
|
||||
const chartData = useMemo(() => MONTHS.map((month, mi) => {
|
||||
const submittedAmt = invoices.filter(i => i.status === 'submitted' && new Date(i.created_at).getFullYear() === chartYear && new Date(i.created_at).getMonth() === mi).reduce((s, i) => s + invoiceTotal(i.items), 0);
|
||||
const paidAmt = invoices.filter(i => i.status === 'paid' && new Date(i.created_at).getFullYear() === chartYear && new Date(i.created_at).getMonth() === mi).reduce((s, i) => s + invoiceTotal(i.items), 0);
|
||||
return { month, Submitted: +submittedAmt.toFixed(2), Paid: +paidAmt.toFixed(2) };
|
||||
}), [invoices, chartYear]);
|
||||
const hasChartData = chartData.some(d => d.Submitted > 0 || d.Paid > 0);
|
||||
const totalPaid = invoices.filter(i => i.status === 'paid').reduce((s, i) => s + invoiceTotal(i.items), 0);
|
||||
const totalPending = invoices.filter(i => i.status === 'submitted').reduce((s, i) => s + invoiceTotal(i.items), 0);
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const [{ data: invoiceRows, error: invoiceError }, { data: taskRows, error: taskError }] = await Promise.all([
|
||||
supabase
|
||||
.from('subcontractor_invoices')
|
||||
.select('*, items:subcontractor_invoice_items(*)')
|
||||
.order('created_at', { ascending: false }),
|
||||
supabase
|
||||
.from('tasks')
|
||||
.select('id, title, status, current_version, assigned_to')
|
||||
.in('status', ['not_started', 'in_progress', 'on_hold', 'client_review', 'client_approved', 'invoiced', 'paid'])
|
||||
.eq('assigned_to', currentUser.id)
|
||||
.order('title'),
|
||||
]);
|
||||
|
||||
if (invoiceError) throw invoiceError;
|
||||
if (taskError) throw taskError;
|
||||
|
||||
const tasks = taskRows || [];
|
||||
const invoicesData = invoiceRows || [];
|
||||
|
||||
const taskIds = tasks.map((task) => task.id).filter(Boolean);
|
||||
const { data: submissionRows, error: submissionsError } = taskIds.length > 0
|
||||
? await supabase
|
||||
.from('submissions')
|
||||
.select('task_id, deliveries(version_number, sent_by, sent_at)')
|
||||
.in('task_id', taskIds)
|
||||
: { data: [], error: null };
|
||||
if (submissionsError) throw submissionsError;
|
||||
|
||||
const deliveriesByTask = new Map();
|
||||
for (const row of (submissionRows || [])) {
|
||||
const taskId = row.task_id;
|
||||
if (!taskId) continue;
|
||||
const bucket = deliveriesByTask.get(taskId) || new Map();
|
||||
for (const delivery of asArray(row.deliveries)) {
|
||||
if ((delivery.sent_by || '').trim().toLowerCase() !== (currentUser.name || '').trim().toLowerCase()) continue;
|
||||
const version = Number(delivery.version_number || 0);
|
||||
if (!bucket.has(version)) bucket.set(version, delivery);
|
||||
}
|
||||
deliveriesByTask.set(taskId, bucket);
|
||||
}
|
||||
|
||||
let completedNewTasks = 0;
|
||||
let completedRevisions = 0;
|
||||
for (const task of tasks) {
|
||||
const versions = [...(deliveriesByTask.get(task.id)?.keys() || [])];
|
||||
for (const version of versions) {
|
||||
if (!isCompletedVersionEligible(task, version)) continue;
|
||||
if (version <= 0) completedNewTasks += 1;
|
||||
else completedRevisions += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const invoiceUnits = invoicesData.reduce((sum, invoice) => sum + asArray(invoice.items).filter((item) => item.task_id).length, 0);
|
||||
const paidUnits = invoicesData
|
||||
.filter((invoice) => invoice.status === 'paid')
|
||||
.reduce((sum, invoice) => sum + asArray(invoice.items).filter((item) => item.task_id).length, 0);
|
||||
|
||||
const nextStats = {
|
||||
completedNewTasks,
|
||||
completedRevisions,
|
||||
invoicedUnits: invoiceUnits,
|
||||
paidUnits,
|
||||
};
|
||||
|
||||
setInvoices(invoicesData);
|
||||
setStats(nextStats);
|
||||
writePageCache(cacheKey, { invoices: invoicesData, stats: nextStats });
|
||||
} catch (err) {
|
||||
console.error('Failed to load subcontractor invoices:', err);
|
||||
setError(err?.message || 'Failed to load invoices.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
}, [cacheKey, currentUser?.id, currentUser?.name]);
|
||||
|
||||
const totalInvoiceAmount = useMemo(
|
||||
() => invoices.reduce((sum, invoice) => sum + invoiceTotal(invoice.items), 0),
|
||||
[invoices]
|
||||
);
|
||||
const paidInvoiceAmount = useMemo(
|
||||
() => invoices.filter((invoice) => invoice.status === 'paid').reduce((sum, invoice) => sum + invoiceTotal(invoice.items), 0),
|
||||
[invoices]
|
||||
);
|
||||
|
||||
const sortedInvoices = sort(invoices, (inv, key) => {
|
||||
if (key === 'total') return invoiceTotal(inv.items);
|
||||
if (key === 'line_count') return asArray(inv.items).length;
|
||||
if (key === 'submitted_at') return inv.submitted_at ? new Date(inv.submitted_at).getTime() : 0;
|
||||
if (key === 'created_at') return inv.created_at ? new Date(inv.created_at).getTime() : 0;
|
||||
return inv[key] || '';
|
||||
});
|
||||
|
||||
async function openInvoicePopup(invoiceId) {
|
||||
if (!invoiceId) return;
|
||||
setDetailLoading(true);
|
||||
setDetailError('');
|
||||
try {
|
||||
const { data, error: err } = await supabase
|
||||
.from('subcontractor_invoices')
|
||||
.select('*, items:subcontractor_invoice_items(*)')
|
||||
.eq('id', invoiceId)
|
||||
.single();
|
||||
if (err || !data) throw err || new Error('Invoice not found.');
|
||||
const taskTypeMap = await fetchTaskTypeMap(asArray(data.items).map((item) => item.task_id));
|
||||
setDetailTaskTypeMap(taskTypeMap);
|
||||
setViewingInvoice(data);
|
||||
} catch (err) {
|
||||
setDetailError(err?.message || 'Failed to load invoice.');
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmitInvoice() {
|
||||
if (!viewingInvoice?.id) return;
|
||||
setSubmittingInvoice(true);
|
||||
setDetailError('');
|
||||
try {
|
||||
const submittedAt = new Date().toISOString();
|
||||
const { error: err } = await supabase
|
||||
.from('subcontractor_invoices')
|
||||
.update({ status: 'submitted', submitted_at: submittedAt })
|
||||
.eq('id', viewingInvoice.id);
|
||||
if (err) throw err;
|
||||
setViewingInvoice((current) => current ? { ...current, status: 'submitted', submitted_at: submittedAt } : current);
|
||||
setInvoices((current) => current.map((invoice) => invoice.id === viewingInvoice.id ? { ...invoice, status: 'submitted', submitted_at: submittedAt } : invoice));
|
||||
} catch (err) {
|
||||
setDetailError(err?.message || 'Failed to submit invoice.');
|
||||
} finally {
|
||||
setSubmittingInvoice(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownloadReceipt() {
|
||||
if (!viewingInvoice) return;
|
||||
setDownloadingReceipt(true);
|
||||
setDetailError('');
|
||||
try {
|
||||
const total = invoiceTotal(viewingInvoice.items);
|
||||
await generateSubcontractorPOPDF(
|
||||
buildPDFArgs(viewingInvoice, { name: currentUser?.name, email: currentUser?.email }, total, 'paid')
|
||||
);
|
||||
} catch (err) {
|
||||
setDetailError(err?.message || 'Failed to download receipt.');
|
||||
} finally {
|
||||
setDownloadingReceipt(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownloadInvoice() {
|
||||
if (!viewingInvoice) return;
|
||||
setDownloadingInvoice(true);
|
||||
setDetailError('');
|
||||
try {
|
||||
const total = invoiceTotal(viewingInvoice.items);
|
||||
await generateSubcontractorPOPDF(
|
||||
buildPDFArgs(viewingInvoice, { name: currentUser?.name, email: currentUser?.email }, total)
|
||||
);
|
||||
} catch (err) {
|
||||
setDetailError(err?.message || 'Failed to download invoice.');
|
||||
} finally {
|
||||
setDownloadingInvoice(false);
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
<Layout loading={loading}>
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
{error && <div className="notification notification-info" style={{ marginBottom: 16 }}>{error}</div>}
|
||||
|
||||
<div className="dash-stat-grid" style={{ gridTemplateColumns: '1fr 1fr 1fr 1fr', flexShrink: 0 }}>
|
||||
<TaskStatCard
|
||||
label="Completed New Tasks"
|
||||
value={stats.completedNewTasks}
|
||||
sub={`${stats.completedNewTasks} completed R00 units`}
|
||||
iconBg="rgba(96,165,250,0.15)"
|
||||
iconColor="#60a5fa"
|
||||
iconPath={STAT_ICONS.newTasks}
|
||||
/>
|
||||
<TaskStatCard
|
||||
label="Completed Revisions"
|
||||
value={stats.completedRevisions}
|
||||
sub={`${stats.completedRevisions} completed revision units`}
|
||||
iconBg="rgba(245,165,35,0.15)"
|
||||
iconColor="#F5A523"
|
||||
iconPath={STAT_ICONS.revisions}
|
||||
/>
|
||||
<TaskStatCard
|
||||
label="Invoiced"
|
||||
value={fmt(totalInvoiceAmount)}
|
||||
sub={`${invoices.length} invoices submitted`}
|
||||
iconBg="rgba(167,139,250,0.15)"
|
||||
iconColor="#a78bfa"
|
||||
iconPath={STAT_ICONS.invoiced}
|
||||
/>
|
||||
<TaskStatCard
|
||||
label="Paid"
|
||||
value={fmt(paidInvoiceAmount)}
|
||||
sub={`${invoices.filter((invoice) => invoice.status === 'paid').length} paid invoices`}
|
||||
iconBg="rgba(74,222,128,0.15)"
|
||||
iconColor="#4ade80"
|
||||
iconPath={STAT_ICONS.paid}
|
||||
/>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={() => navigate('/my-invoices-sub/new')} disabled={loading}>
|
||||
+ New Invoice
|
||||
</button>
|
||||
|
||||
{invoices.length === 0 ? (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10, flexShrink: 0, minHeight: 'var(--btn-height)' }}>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<button className="btn btn-outline" onClick={() => setShowInvoiceForm(true)} disabled={loading}>
|
||||
+ Invoice
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card card-empty-center" style={{ flex: 1, minHeight: 0 }}>No invoices</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10, flexShrink: 0, minHeight: 'var(--btn-height)' }}>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<button className="btn btn-outline" onClick={() => setShowInvoiceForm(true)} disabled={loading}>
|
||||
+ Invoice
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--card-bg)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
padding: '18px 21px',
|
||||
backdropFilter: 'blur(12px)',
|
||||
WebkitBackdropFilter: 'blur(12px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
<table className="table-sticky-head" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
|
||||
<colgroup>
|
||||
<col style={{ width: '28%' }} />
|
||||
<col style={{ width: '15%' }} />
|
||||
<col style={{ width: '15%' }} />
|
||||
<col style={{ width: '15%' }} />
|
||||
<col style={{ width: '12%' }} />
|
||||
<col style={{ width: '15%' }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="invoice_number" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Invoice #</SortTh>
|
||||
<SortTh col="created_at" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Created</SortTh>
|
||||
<SortTh col="submitted_at" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Submitted</SortTh>
|
||||
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Status</SortTh>
|
||||
<SortTh col="line_count" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'center' }}>Items</SortTh>
|
||||
<SortTh col="total" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Total</SortTh>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedInvoices.map((inv) => {
|
||||
const total = invoiceTotal(inv.items);
|
||||
return (
|
||||
<tr key={inv.id} style={{ cursor: 'pointer' }} onClick={() => openInvoicePopup(inv.id)}>
|
||||
<td style={{ fontWeight: 400 }}>{inv.invoice_number || 'Subcontractor Invoice'}</td>
|
||||
<td style={{ color: 'var(--text-muted)' }}>{inv.created_at ? new Date(inv.created_at).toLocaleDateString() : '—'}</td>
|
||||
<td style={{ color: 'var(--text-muted)' }}>{inv.submitted_at ? new Date(inv.submitted_at).toLocaleDateString() : '—'}</td>
|
||||
<td><StatusBadge status={STATUS_BADGE[inv.status]} label={STATUS_LABEL[inv.status]} /></td>
|
||||
<td style={{ textAlign: 'center', color: 'var(--text-primary)' }}>{asArray(inv.items).length}</td>
|
||||
<td style={{ textAlign: 'right', fontWeight: 400, color: 'var(--accent)' }}>{fmt(total)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="notification notification-info" style={{ marginBottom: 16 }}>{error}</div>}
|
||||
|
||||
{!loading && invoices.length > 0 && (
|
||||
<>
|
||||
<div className="stat-bar" style={{ marginBottom: 18 }}>
|
||||
<div className="stat-bar-item"><div className="stat-bar-header"><div className="stat-bar-label">Total Paid</div><div className="stat-bar-dot" style={{ background: '#4ade80' }} /></div><div className="stat-bar-value">{fmt(totalPaid)}</div></div>
|
||||
<div className="stat-bar-item"><div className="stat-bar-header"><div className="stat-bar-label">Pending Payment</div><div className="stat-bar-dot" style={{ background: '#F5A523' }} /></div><div className="stat-bar-value">{fmt(totalPending)}</div></div>
|
||||
<div className="stat-bar-item"><div className="stat-bar-header"><div className="stat-bar-label">Total Invoices</div><div className="stat-bar-dot" style={{ background: '#60a5fa' }} /></div><div className="stat-bar-value">{invoices.length}</div></div>
|
||||
{showInvoiceForm && (
|
||||
<div style={popupOverlayStyle} onClick={() => setShowInvoiceForm(false)}>
|
||||
<div
|
||||
style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<SubcontractorInvoiceForm
|
||||
embedded
|
||||
onCancel={() => setShowInvoiceForm(false)}
|
||||
onSubmitted={(inv) => {
|
||||
setShowInvoiceForm(false);
|
||||
openInvoicePopup(inv.id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{hasChartData && (
|
||||
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '16px 16px 8px', marginBottom: 18 }}>
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="gradSubAmt" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#F5A523" stopOpacity={0.25}/><stop offset="95%" stopColor="#F5A523" stopOpacity={0}/></linearGradient>
|
||||
<linearGradient id="gradSubPaid" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#4ade80" stopOpacity={0.25}/><stop offset="95%" stopColor="#4ade80" stopOpacity={0}/></linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} />
|
||||
<XAxis dataKey="month" tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} tickFormatter={v => `$${v >= 1000 ? (v/1000).toFixed(0)+'k' : v}`} width={45} />
|
||||
<Tooltip formatter={(v) => [`$${Number(v).toLocaleString('en-US', { minimumFractionDigits: 2 })}`, undefined]} contentStyle={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, fontSize: 12 }} />
|
||||
<Legend wrapperStyle={{ fontSize: 11, paddingTop: 8 }} />
|
||||
<Area type="monotone" dataKey="Submitted" stroke="#F5A523" strokeWidth={2} fill="url(#gradSubAmt)" dot={false} activeDot={{ r: 4 }} />
|
||||
<Area type="monotone" dataKey="Paid" stroke="#4ade80" strokeWidth={2} fill="url(#gradSubPaid)" dot={false} activeDot={{ r: 4 }} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</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="card">
|
||||
<div className="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="invoice_number" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Invoice #</SortTh>
|
||||
<SortTh col="submitted_at" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Submitted</SortTh>
|
||||
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Status</SortTh>
|
||||
<SortTh col="total" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Total</SortTh>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sort(invoices, (inv, key) => {
|
||||
if (key === 'total') return invoiceTotal(inv.items);
|
||||
if (key === 'submitted_at') return inv.submitted_at ? new Date(inv.submitted_at).getTime() : 0;
|
||||
return inv[key] || '';
|
||||
}).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: 400 }}>{inv.invoice_number}</td>
|
||||
<td style={{ color: 'var(--text-muted)' }}>{inv.submitted_at ? new Date(inv.submitted_at).toLocaleDateString() : '—'}</td>
|
||||
<td><StatusBadge status={STATUS_BADGE[inv.status]} label={STATUS_LABEL[inv.status]} /></td>
|
||||
<td style={{ textAlign: 'right', fontWeight: 400, color: 'var(--accent)' }}>{fmt(total)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{(viewingInvoice || detailLoading || detailError) && (
|
||||
<div style={popupOverlayStyle} onClick={() => { if (!submittingInvoice && !downloadingInvoice && !downloadingReceipt) { setViewingInvoice(null); setDetailError(''); } }}>
|
||||
<div
|
||||
style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{detailLoading && !viewingInvoice ? (
|
||||
<div className="card-empty-center" style={{ flex: 1, minHeight: 0 }}>Loading invoice…</div>
|
||||
) : viewingInvoice ? (() => {
|
||||
const total = invoiceTotal(viewingInvoice.items);
|
||||
const sortedItems = [...(viewingInvoice.items || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
|
||||
const invoiceStatus = viewingInvoice.status ? viewingInvoice.status.charAt(0).toUpperCase() + viewingInvoice.status.slice(1) : '—';
|
||||
return (
|
||||
<>
|
||||
{detailError ? <div className="notification notification-info" style={{ marginBottom: 16, flexShrink: 0 }}>{detailError}</div> : null}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16, paddingBottom: 18, borderBottom: '1px solid var(--border)' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 18, fontWeight: 500, color: 'var(--text-primary)', letterSpacing: 0.2, lineHeight: 1.1 }}>{viewingInvoice.invoice_number || 'Subcontractor Invoice'}</div>
|
||||
<div style={{ marginTop: 6, fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||
{currentUser?.name || 'Subcontractor'}{currentUser?.email ? ` · ${currentUser.email}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={STATUS_BADGE[viewingInvoice.status] || 'not_started'} label={invoiceStatus} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 18, padding: '18px 0', borderBottom: '1px solid var(--border)' }}>
|
||||
<div>
|
||||
<div style={FIELD_LABEL_STYLE}>Created</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{viewingInvoice.created_at ? new Date(viewingInvoice.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={FIELD_LABEL_STYLE}>Submitted</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{viewingInvoice.submitted_at ? new Date(viewingInvoice.submitted_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={FIELD_LABEL_STYLE}>Line Items</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{sortedItems.length}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={FIELD_LABEL_STYLE}>Total</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 500, color: 'var(--accent)', lineHeight: 1.1 }}>${total.toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto', paddingTop: 18 }}>
|
||||
{sortedItems.length === 0 ? (
|
||||
<div className="card-empty-center">No line items</div>
|
||||
) : (
|
||||
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||
<colgroup>
|
||||
<col style={{ width: '8%' }} />
|
||||
<col style={{ width: '10%' }} />
|
||||
<col style={{ width: '32%' }} />
|
||||
<col style={{ width: '16%' }} />
|
||||
<col style={{ width: '10%' }} />
|
||||
<col style={{ width: '12%' }} />
|
||||
<col style={{ width: '12%' }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'center' }}>Work</th>
|
||||
<th style={{ textAlign: 'center' }}>R#</th>
|
||||
<th style={{ textAlign: 'left' }}>Description</th>
|
||||
<th style={{ textAlign: 'center' }}>Type</th>
|
||||
<th style={{ textAlign: 'center' }}>Qty</th>
|
||||
<th style={{ textAlign: 'center' }}>Unit Price</th>
|
||||
<th style={{ textAlign: 'center' }}>Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedItems.map(item => (
|
||||
<tr key={item.id}>
|
||||
<td style={{ padding: '5px 0', fontSize: 13, color: 'var(--text-primary)', textAlign: 'center' }}>{itemWorkLabel(item)}</td>
|
||||
<td style={{ padding: '5px 0', fontSize: 13, color: 'var(--text-primary)', textAlign: 'center' }}>{itemVersionLabel(item)}</td>
|
||||
<td style={{ padding: '5px 0', fontSize: 13, color: 'var(--text-primary)' }}>{cleanItemDescription(item)}</td>
|
||||
<td style={{ padding: '5px 0', fontSize: 13, color: 'var(--text-primary)', textAlign: 'center' }}>{detailTaskTypeMap[item.task_id] || 'Other'}</td>
|
||||
<td style={{ padding: '5px 0', textAlign: 'center', fontSize: 13, color: 'var(--text-primary)' }}>{item.quantity || 1}</td>
|
||||
<td style={{ padding: '5px 0', textAlign: 'center', fontSize: 13, color: 'var(--text-primary)' }}>${Number(item.unit_price || 0).toFixed(2)}</td>
|
||||
<td style={{ padding: '5px 0', textAlign: 'center', fontSize: 13, color: 'var(--text-primary)' }}>${(Number(item.unit_price || 0) * Number(item.quantity || 1)).toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{viewingInvoice.notes && (
|
||||
<div style={{ marginTop: 18, padding: '12px 14px', background: 'var(--card-bg-2)', borderRadius: 6, border: '1px solid var(--border)' }}>
|
||||
<div style={FIELD_LABEL_STYLE}>Notes</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)', whiteSpace: 'pre-wrap' }}>{viewingInvoice.notes}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="modal-action-row" style={{ paddingTop: 18, borderTop: '1px solid var(--border)' }}>
|
||||
<LoadingButton className="btn btn-outline" loading={downloadingInvoice} loadingText="Generating…" disabled={downloadingInvoice || submittingInvoice || downloadingReceipt} onClick={handleDownloadInvoice}>
|
||||
Download Invoice
|
||||
</LoadingButton>
|
||||
{viewingInvoice.status === 'draft' && (
|
||||
<LoadingButton className="btn btn-outline" loading={submittingInvoice} loadingText="Submitting…" disabled={submittingInvoice || downloadingInvoice || downloadingReceipt} onClick={handleSubmitInvoice}>
|
||||
Submit to Team
|
||||
</LoadingButton>
|
||||
)}
|
||||
{viewingInvoice.status === 'paid' && (
|
||||
<LoadingButton className="btn btn-outline" loading={downloadingReceipt} loadingText="Generating…" disabled={downloadingReceipt || downloadingInvoice || submittingInvoice} onClick={handleDownloadReceipt}>
|
||||
Download Receipt
|
||||
</LoadingButton>
|
||||
)}
|
||||
<button type="button" className="btn btn-outline" onClick={() => { setViewingInvoice(null); setDetailError(''); }} disabled={submittingInvoice || downloadingInvoice || downloadingReceipt}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})() : (
|
||||
<div className="card-empty-center" style={{ flex: 1, minHeight: 0 }}>{detailError || 'Invoice not found.'}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+3
-5
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Layout from '../../components/Layout';
|
||||
import PageLoader from '../../components/PageLoader';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
@@ -95,14 +96,11 @@ export default function MyPurchaseOrders() {
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p style={{ color: 'var(--text-muted)' }}>Loading...</p>
|
||||
<PageLoader />
|
||||
) : error ? (
|
||||
<div className="card" style={{ color: 'var(--danger)' }}>{error}</div>
|
||||
) : purchaseOrders.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>No purchase orders</h3>
|
||||
<p>New POs will appear here when the Fourge team sends them.</p>
|
||||
</div>
|
||||
<div className="card card-empty-center">No purchase orders</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
{purchaseOrders.map(po => (
|
||||
|
||||
@@ -7,6 +7,8 @@ import { useAuth } from '../../context/AuthContext';
|
||||
import { generateInvoicePDF } from '../../lib/invoice';
|
||||
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
|
||||
import { withTimeout } from '../../lib/withTimeout';
|
||||
import { isReviewShadowDescription } from '../../lib/taskVersions';
|
||||
import { getRevisionChargeQuantity, isCompletedVersionEligible, isInitialVersionEligible } from '../../lib/invoiceVersionRules';
|
||||
|
||||
// Computed at module load time — stable for the lifetime of the invoice creation session
|
||||
const INVOICE_TODAY = new Date().toISOString().split('T')[0];
|
||||
@@ -29,12 +31,6 @@ const buildRevisionItemDescription = (revision) => {
|
||||
return `${projectName} • ${taskTitle} • Revision ${versionLabel}`;
|
||||
};
|
||||
|
||||
const getRevisionChargeQuantity = (revision) => {
|
||||
const version = Number(revision?.version_number || 0);
|
||||
// Incremental billing: R01 is free, each uninvoiced revision from R02+ is one charge unit.
|
||||
return version >= 2 ? 1 : 0;
|
||||
};
|
||||
|
||||
export default function CreateInvoice() {
|
||||
const navigate = useNavigate();
|
||||
const { currentUser } = useAuth();
|
||||
@@ -96,30 +92,30 @@ export default function CreateInvoice() {
|
||||
setInvoiceEmail(recipients[0]?.email || companies.find(c => c.id === selectedCompanyId)?.contact_email || '');
|
||||
if (projects && projects.length > 0) {
|
||||
const projectIds = projects.map(p => p.id);
|
||||
const { data: tasks } = await supabase
|
||||
const { data: companyTasks } = await supabase
|
||||
.from('tasks')
|
||||
.select('*, project:projects(name), submissions(service_type, type, version_number)')
|
||||
.in('project_id', projectIds)
|
||||
.eq('invoiced', false)
|
||||
.eq('status', 'client_approved');
|
||||
const approvedTaskIds = (tasks || []).map((t) => t.id).filter(Boolean);
|
||||
const { data: revisions } = approvedTaskIds.length > 0
|
||||
.in('status', ['not_started', 'in_progress', 'on_hold', 'client_review', 'client_approved', 'invoiced', 'paid']);
|
||||
const companyTaskIds = (companyTasks || []).map((t) => t.id).filter(Boolean);
|
||||
const { data: revisions } = companyTaskIds.length > 0
|
||||
? await supabase
|
||||
.from('submissions')
|
||||
.select('*, task:tasks(id, title, project:projects(name), submissions(service_type, type))')
|
||||
.select('*, task:tasks(id, title, status, current_version, project:projects(name), submissions(service_type, type))')
|
||||
.eq('type', 'revision')
|
||||
.or('revision_type.eq.client_revision,revision_type.is.null')
|
||||
.eq('invoiced', false)
|
||||
.in('task_id', approvedTaskIds)
|
||||
.in('task_id', companyTaskIds)
|
||||
.order('submitted_at', { ascending: false })
|
||||
: { data: [] };
|
||||
const tasksWithService = (tasks || []).map(t => {
|
||||
const tasksWithService = (companyTasks || []).filter(isInitialVersionEligible).map(t => {
|
||||
const initial = (t.submissions || []).find(s => s.type === 'initial') || (t.submissions || [])[0];
|
||||
return { ...t, service_type: initial?.service_type || t.title };
|
||||
});
|
||||
setUninvoicedTasks(tasksWithService);
|
||||
// Deduplicate by (task_id, version_number) — multiple submission rows per version (e.g. "Add Files") must not produce multiple invoice charges
|
||||
const revMap = new Map();
|
||||
for (const rev of (revisions || [])) {
|
||||
for (const rev of (revisions || []).filter(rev => !isReviewShadowDescription(rev.description))) {
|
||||
if (!isCompletedVersionEligible(rev.task, rev.version_number)) continue;
|
||||
const key = `${rev.task_id}:${rev.version_number}`;
|
||||
if (!revMap.has(key)) revMap.set(key, rev);
|
||||
}
|
||||
@@ -152,7 +148,7 @@ export default function CreateInvoice() {
|
||||
const serviceLabel = getRevisionServiceType(revision);
|
||||
const description = buildRevisionItemDescription(revision);
|
||||
const price = priceList.find(p => p.service_type === serviceLabel && p.price_type === 'revision');
|
||||
const revisionChargeQty = getRevisionChargeQuantity(revision);
|
||||
const revisionChargeQty = getRevisionChargeQuantity(revision?.version_number);
|
||||
const quantity = revisionChargeQty > 0 ? revisionChargeQty : 1;
|
||||
const unitPrice = revisionChargeQty > 0 ? (price?.price || '') : 0;
|
||||
setItems(prev => {
|
||||
@@ -324,7 +320,7 @@ export default function CreateInvoice() {
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<button className="back-link" onClick={() => navigate('/invoices')}>← Back to Invoices</button>
|
||||
<button className="back-link" onClick={() => navigate('/finances')}>← Back to Invoices</button>
|
||||
|
||||
<div className="page-header">
|
||||
<div>
|
||||
@@ -403,9 +399,10 @@ export default function CreateInvoice() {
|
||||
<div key={task.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 400 }}>{buildNewItemDescription(task)}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
{task.service_type || 'Other'} • {price ? `$${Number(price.price).toFixed(2)}` : 'No price set'}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
<span className="badge badge-initial">New</span>
|
||||
<span>{task.service_type || 'Other'} • {price ? `$${Number(price.price).toFixed(2)}` : 'No price set'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className={`btn btn-sm ${alreadyAdded ? 'btn-outline' : 'btn-primary'}`}
|
||||
@@ -441,13 +438,15 @@ export default function CreateInvoice() {
|
||||
{uninvoicedRevisions.map(rev => {
|
||||
const revServiceType = getRevisionServiceType(rev);
|
||||
const price = priceList.find(p => p.service_type === revServiceType && p.price_type === 'revision');
|
||||
const revisionChargeQty = getRevisionChargeQuantity(rev?.version_number);
|
||||
const alreadyAdded = items.some(i => i.submission_id === rev.id);
|
||||
return (
|
||||
<div key={rev.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 400 }}>{buildRevisionItemDescription(rev)}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
{revServiceType || 'Other'} • {price ? `$${Number(price.price).toFixed(2)}` : 'No price set'}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
<span className="badge badge-client_revision">Revision</span>
|
||||
<span>{revServiceType || 'Other'} • {revisionChargeQty > 0 ? (price ? `$${Number(price.price).toFixed(2)}` : 'No price set') : 'Free'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -537,7 +536,7 @@ export default function CreateInvoice() {
|
||||
<LoadingButton className="btn btn-primary" onClick={() => handleSave('sent')} loading={saving} loadingText="Finalizing & Sending...">
|
||||
Finalise & Send
|
||||
</LoadingButton>
|
||||
<button className="btn btn-outline" onClick={() => navigate('/invoices')}>Cancel</button>
|
||||
<button className="btn btn-outline" onClick={() => navigate('/finances')}>Cancel</button>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { sendEmail } from '../../lib/email';
|
||||
|
||||
const FIELD_LABEL_STYLE = { fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', marginBottom: 4 };
|
||||
const FIELD_INPUT_STYLE = { minHeight: 42, margin: 0 };
|
||||
@@ -180,35 +179,12 @@ export default function CreateSubcontractorPO() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === 'sent') {
|
||||
const subcontractor = externalProfiles.find(profile => profile.id === form.profile_id);
|
||||
const project = projects.find(row => row.id === form.project_id);
|
||||
if (subcontractor?.email) {
|
||||
try {
|
||||
await sendEmail('subcontractor_po_sent', subcontractor.email, {
|
||||
poNumber: data.po_number || 'Purchase Order',
|
||||
subcontractorName: subcontractor.name || 'there',
|
||||
projectName: project?.name || 'Subcontractor Work',
|
||||
companyName: project?.company?.name || 'Fourge Branding',
|
||||
amount: `$${total.toFixed(2)}`,
|
||||
dueDate: form.due_date ? new Date(form.due_date).toLocaleDateString() : 'Not set',
|
||||
terms: form.terms.trim() || 'Net 15',
|
||||
scope: lineItems.map(item => `${item.description} - $${item.amount.toFixed(2)}`).join('\n'),
|
||||
portalUrl: 'https://portal.fourgebranding.com/my-purchase-orders',
|
||||
});
|
||||
} catch (emailError) {
|
||||
alert(`PO was finalized, but the email failed: ${emailError.message || 'unknown error'}`);
|
||||
}
|
||||
} else {
|
||||
alert('PO was finalized, but this subcontractor has no email on file.');
|
||||
}
|
||||
}
|
||||
navigate('/invoices', { state: { tab: 'subcontractor-po' } });
|
||||
navigate('/finances', { state: { tab: 'subcontractor-po' } });
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<button className="back-link" onClick={() => navigate('/invoices', { state: { tab: 'subcontractor-po' } })}>← Back to Subcontractor PO</button>
|
||||
<Layout loading={loading}>
|
||||
<button className="back-link" onClick={() => navigate('/finances', { state: { tab: 'subcontractor-po' } })}>← Back to Subcontractor PO</button>
|
||||
|
||||
<div className="page-header">
|
||||
<div>
|
||||
|
||||
+228
-249
@@ -1,15 +1,18 @@
|
||||
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import PageLoader from '../../components/PageLoader';
|
||||
import SortTh from '../../components/SortTh';
|
||||
import ProfileAvatar from '../../components/ProfileAvatar';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
||||
import { withTimeout } from '../../lib/withTimeout';
|
||||
import { getDeadlineSourceSubmission } from '../../lib/taskDeadlines';
|
||||
import { useSortable } from '../../hooks/useSortable';
|
||||
import { useRefetchOnFocus } from '../../hooks/useRefetchOnFocus';
|
||||
import { useRealtimeSubscription } from '../../hooks/useRealtimeSubscription';
|
||||
import { useLiveRefresh } from '../../hooks/useLiveRefresh';
|
||||
import { resolveScopedWorkIds } from '../../lib/workScope';
|
||||
import { getSubmissionDisplayName, mergeSubmissionDisplayNames } from '../../lib/submissionDisplay';
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -32,22 +35,6 @@ function fmtMoney(n) {
|
||||
return `$${Number(n).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function buildClientHighlights(companies, projects, tasks, clientProfiles, companyMemberships = [], invoices = []) {
|
||||
const doneStatuses = ['client_approved', 'invoiced', 'paid'];
|
||||
return (companies || []).map(company => {
|
||||
const companyProjects = (projects || []).filter(p => p.company_id === company.id);
|
||||
const primaryContact = (clientProfiles || []).find(p =>
|
||||
p.company_id === company.id ||
|
||||
(companyMemberships || []).some(m => m.company_id === company.id && m.profile_id === p.id)
|
||||
);
|
||||
const openTasks = (tasks || []).filter(t => companyProjects.some(p => p.id === t.project_id) && !doneStatuses.includes(t.status));
|
||||
const companyInvoices = (invoices || []).filter(i => i.company_id === company.id);
|
||||
const outstandingTotal = companyInvoices.filter(i => i.status === 'sent').reduce((s, i) => s + Number(i.total || 0), 0);
|
||||
const paidTotal = companyInvoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total || 0), 0);
|
||||
return { company, primaryContact, projectCount: companyProjects.length, openTaskCount: openTasks.length, outstandingTotal, paidTotal };
|
||||
});
|
||||
}
|
||||
|
||||
const ACTION_ICON = {
|
||||
task_started: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: <polygon points="6,4 20,12 6,20" fill="currentColor"/> },
|
||||
task_resumed: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: <polygon points="6,4 20,12 6,20" fill="currentColor"/> },
|
||||
@@ -75,25 +62,6 @@ function ActionIcon({ actionKey, size = 27 }) {
|
||||
);
|
||||
}
|
||||
|
||||
function Avatar({ name, size = 27 }) {
|
||||
return (
|
||||
<div style={{ width: size, height: size, borderRadius: '50%', background: 'rgba(245,165,35,0.15)', flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="#F5A523">
|
||||
<circle cx="12" cy="8" r="4" />
|
||||
<path d="M4 20c0-4.4 3.6-7 8-7s8 2.6 8 7" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InitialPortrait({ name }) {
|
||||
return (
|
||||
<div style={{ width: 27, height: 27, borderRadius: '50%', background: 'var(--accent)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: '#000', lineHeight: 1 }}>{(name || '?')[0].toUpperCase()}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function smoothCurve(pts) {
|
||||
if (pts.length < 2) return '';
|
||||
let d = `M${pts[0][0].toFixed(1)},${pts[0][1].toFixed(1)}`;
|
||||
@@ -373,170 +341,159 @@ function TasksInProgressCard({ tasks = [] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function HotItemsCard({ submissions, tasks, isClient = false }) {
|
||||
function HotItemsCard({ submissions, tasks, isClient = false, isExternal = false, currentUserId = null, cardHeight = null }) {
|
||||
const navigate = useNavigate();
|
||||
const { sortKey, sortDir, toggle, sort } = useSortable('deadline');
|
||||
const taskMap = new Map((tasks || []).map(t => [t.id, t]));
|
||||
const seen = new Set();
|
||||
const hotItems = (submissions || [])
|
||||
.filter(s => {
|
||||
const task = taskMap.get(s.task_id);
|
||||
if (isClient) return task?.status === 'client_review' && !seen.has(s.task_id) && seen.add(s.task_id);
|
||||
const activeStatuses = ['not_started', 'in_progress', 'client_review', 'on_hold'];
|
||||
return s.is_hot && activeStatuses.includes(task?.status) && !seen.has(s.task_id) && seen.add(s.task_id);
|
||||
})
|
||||
.map(s => ({ ...s, task: taskMap.get(s.task_id) }))
|
||||
.filter(s => s.task)
|
||||
.sort((a, b) => {
|
||||
if (!a.deadline && !b.deadline) return 0;
|
||||
if (!a.deadline) return 1;
|
||||
if (!b.deadline) return -1;
|
||||
return new Date(a.deadline) - new Date(b.deadline);
|
||||
})
|
||||
.slice(0, 7);
|
||||
const activeStatuses = ['not_started', 'in_progress', 'client_review', 'on_hold'];
|
||||
const hotItems = isExternal
|
||||
? (tasks || [])
|
||||
.filter(task => task?.assigned_to === currentUserId && activeStatuses.includes(task?.status))
|
||||
.sort((a, b) => {
|
||||
if (!a.deadline && !b.deadline) return 0;
|
||||
if (!a.deadline) return 1;
|
||||
if (!b.deadline) return -1;
|
||||
return new Date(a.deadline) - new Date(b.deadline);
|
||||
})
|
||||
.slice(0, 8)
|
||||
: (submissions || [])
|
||||
.filter(s => {
|
||||
const task = taskMap.get(s.task_id);
|
||||
if (isClient) return task?.status === 'client_review' && !seen.has(s.task_id) && seen.add(s.task_id);
|
||||
return s.is_hot && activeStatuses.includes(task?.status) && !seen.has(s.task_id) && seen.add(s.task_id);
|
||||
})
|
||||
.map(s => ({ ...s, task: taskMap.get(s.task_id) }))
|
||||
.filter(s => s.task)
|
||||
.sort((a, b) => {
|
||||
if (!a.deadline && !b.deadline) return 0;
|
||||
if (!a.deadline) return 1;
|
||||
if (!b.deadline) return -1;
|
||||
return new Date(a.deadline) - new Date(b.deadline);
|
||||
})
|
||||
.slice(0, 7);
|
||||
const sortedHotItems = sort(hotItems, (s, key) => {
|
||||
if (isExternal) {
|
||||
if (key === 'task') return s.title || '';
|
||||
if (key === 'project') return s.project_name || '';
|
||||
if (key === 'status') return s.status || '';
|
||||
if (key === 'deadline') return s.deadline || '';
|
||||
return '';
|
||||
}
|
||||
if (key === 'task') return s.task?.title || '';
|
||||
if (key === 'requested_by') return s.submitted_by_name || '';
|
||||
if (key === 'requested_by') return getSubmissionDisplayName(s) || '';
|
||||
if (key === 'deadline') return s.deadline || '';
|
||||
return '';
|
||||
});
|
||||
return (
|
||||
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column', height: cardHeight || 'auto', minHeight: cardHeight ? 0 : 280 }}>
|
||||
<div style={{ marginBottom: hotItems.length > 0 ? 14 : 0, flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>
|
||||
{isClient ? 'Tasks Ready For Review' : 'Hot Tasks'}
|
||||
{isClient ? 'Tasks Ready For Review' : isExternal ? 'My Tasks' : 'Hot Tasks'}
|
||||
</span>
|
||||
</div>
|
||||
{hotItems.length === 0 ? (
|
||||
<div style={{ minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>
|
||||
{isClient ? 'No tasks ready for review' : 'No hot tasks'}
|
||||
<div style={{ flex: 1, minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>
|
||||
{isClient ? 'No tasks ready for review' : isExternal ? 'No active tasks assigned to you' : 'No hot tasks'}
|
||||
</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||
<colgroup>
|
||||
<col style={{ width: '10%' }} />
|
||||
<col style={{ width: '40%' }} />
|
||||
<col style={{ width: '35%' }} />
|
||||
<col style={{ width: '15%' }} />
|
||||
</colgroup>
|
||||
<thead style={{ background: 'transparent' }}>
|
||||
<tr style={{ background: 'transparent' }}>
|
||||
<th style={{ padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top', textAlign: 'center' }} />
|
||||
<SortTh col="task" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'left', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 5px', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Task</SortTh>
|
||||
<SortTh col="requested_by" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Requested By</SortTh>
|
||||
<SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Due By</SortTh>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedHotItems.map(s => (
|
||||
<tr key={s.task_id} style={{ verticalAlign: 'middle', background: 'transparent' }}>
|
||||
<td style={{ padding: '3px 5px 7px', border: 'none', background: 'transparent', textAlign: 'center', verticalAlign: 'middle' }}>
|
||||
{s.task.status === 'client_approved' ? (
|
||||
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="rgba(74,222,128,0.8)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'block', margin: '0 auto' }}>
|
||||
<rect x="1.5" y="1.5" width="13" height="13" rx="2"/><polyline points="4,8 6.5,10.5 12,5.5"/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="var(--text-muted)" strokeWidth="1.5" strokeLinecap="round" style={{ display: 'block', margin: '0 auto' }}>
|
||||
<rect x="1.5" y="1.5" width="13" height="13" rx="2"/>
|
||||
</svg>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', padding: '5px', border: 'none', background: 'transparent', textAlign: 'left' }}>
|
||||
<button type="button" className="dashboard-inline-link" onClick={() => navigate('/tasks/' + s.task_id)}>{s.task.title}</button>
|
||||
</td>
|
||||
<td style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', padding: '5px', border: 'none', background: 'transparent', textAlign: 'center' }}>
|
||||
{s.submitted_by ? (
|
||||
<button type="button" className="dashboard-inline-link" onClick={() => navigate(profilePath(s.submitted_by, s.submitter_role))}>{s.submitted_by_name || 'Profile'}</button>
|
||||
) : (s.submitted_by_name || '—')}
|
||||
</td>
|
||||
<td style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap', textAlign: 'center', padding: '5px', border: 'none', background: 'transparent' }}>{s.deadline ? new Date(s.deadline).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||
{isExternal ? (
|
||||
<>
|
||||
<colgroup>
|
||||
<col style={{ width: '42%' }} />
|
||||
<col style={{ width: '24%' }} />
|
||||
<col style={{ width: '19%' }} />
|
||||
<col style={{ width: '15%' }} />
|
||||
</colgroup>
|
||||
<thead style={{ background: 'transparent' }}>
|
||||
<tr style={{ background: 'transparent' }}>
|
||||
<SortTh col="task" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'left', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 5px', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Task</SortTh>
|
||||
<SortTh col="project" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'left', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Project</SortTh>
|
||||
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Status</SortTh>
|
||||
<SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Due By</SortTh>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedHotItems.map((task) => (
|
||||
<tr key={task.id} style={{ verticalAlign: 'middle', background: 'transparent' }}>
|
||||
<td style={{ fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', padding: '5px', border: 'none', background: 'transparent', textAlign: 'left' }}>
|
||||
<button type="button" className="dashboard-inline-link" onClick={() => navigate('/tasks/' + task.id)}>{task.title}</button>
|
||||
</td>
|
||||
<td style={{ fontSize: 12, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', padding: '5px', border: 'none', background: 'transparent', textAlign: 'left' }}>{task.project?.name || '—'}</td>
|
||||
<td style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap', textAlign: 'center', padding: '5px', border: 'none', background: 'transparent', textTransform: 'capitalize' }}>{String(task.status || '').replace(/_/g, ' ') || '—'}</td>
|
||||
<td style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap', textAlign: 'center', padding: '5px', border: 'none', background: 'transparent' }}>{task.deadline ? new Date(task.deadline).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<colgroup>
|
||||
<col style={{ width: '10%' }} />
|
||||
<col style={{ width: '40%' }} />
|
||||
<col style={{ width: '35%' }} />
|
||||
<col style={{ width: '15%' }} />
|
||||
</colgroup>
|
||||
<thead style={{ background: 'transparent' }}>
|
||||
<tr style={{ background: 'transparent' }}>
|
||||
<th style={{ padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top', textAlign: 'center' }} />
|
||||
<SortTh col="task" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'left', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 5px', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Task</SortTh>
|
||||
<SortTh col="requested_by" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Requested By</SortTh>
|
||||
<SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Due By</SortTh>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedHotItems.map(s => (
|
||||
<tr key={s.task_id} style={{ verticalAlign: 'middle', background: 'transparent' }}>
|
||||
<td style={{ padding: '3px 5px 7px', border: 'none', background: 'transparent', textAlign: 'center', verticalAlign: 'middle' }}>
|
||||
{s.task.status === 'client_approved' ? (
|
||||
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="rgba(74,222,128,0.8)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'block', margin: '0 auto' }}>
|
||||
<rect x="1.5" y="1.5" width="13" height="13" rx="2"/><polyline points="4,8 6.5,10.5 12,5.5"/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="var(--text-muted)" strokeWidth="1.5" strokeLinecap="round" style={{ display: 'block', margin: '0 auto' }}>
|
||||
<rect x="1.5" y="1.5" width="13" height="13" rx="2"/>
|
||||
</svg>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', padding: '5px', border: 'none', background: 'transparent', textAlign: 'left' }}>
|
||||
<button type="button" className="dashboard-inline-link" onClick={() => navigate('/tasks/' + s.task_id)}>{s.task.title}</button>
|
||||
</td>
|
||||
<td style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', padding: '5px', border: 'none', background: 'transparent', textAlign: 'center' }}>
|
||||
{s.submitted_by ? (
|
||||
<button type="button" className="dashboard-inline-link" onClick={() => navigate(profilePath(s.submitted_by, s.submitter_role))}>{getSubmissionDisplayName(s) || 'Profile'}</button>
|
||||
) : (getSubmissionDisplayName(s) || '—')}
|
||||
</td>
|
||||
<td style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap', textAlign: 'center', padding: '5px', border: 'none', background: 'transparent' }}>{s.deadline ? new Date(s.deadline).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientHighlightCard({ highlights }) {
|
||||
const navigate = useNavigate();
|
||||
const { sortKey, sortDir, toggle, sort } = useSortable('company');
|
||||
const fmt = (n) => `$${Number(n || 0).toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`;
|
||||
const thStyle = { fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' };
|
||||
const td = () => ({ padding: '5px', border: 'none', background: 'transparent', textAlign: 'center', verticalAlign: 'middle' });
|
||||
const sortedHighlights = sort(highlights || [], (row, key) => {
|
||||
if (key === 'company') return row.company?.name || '';
|
||||
if (key === 'contact') return row.primaryContact?.name || '';
|
||||
if (key === 'projects') return row.projectCount || 0;
|
||||
if (key === 'open_tasks') return row.openTaskCount || 0;
|
||||
if (key === 'outstanding') return Number(row.outstandingTotal || 0);
|
||||
if (key === 'paid') return Number(row.paidTotal || 0);
|
||||
return '';
|
||||
});
|
||||
return (
|
||||
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ marginBottom: highlights && highlights.length > 0 ? 14 : 0, flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Client Highlight</span>
|
||||
</div>
|
||||
{!highlights || highlights.length === 0 ? (
|
||||
<div style={{ minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No data</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||
<colgroup>
|
||||
<col style={{ width: '5%' }} />
|
||||
<col style={{ width: '22%' }} />
|
||||
<col style={{ width: '23%' }} />
|
||||
<col style={{ width: '13%' }} />
|
||||
<col style={{ width: '13%' }} />
|
||||
<col style={{ width: '12%' }} />
|
||||
<col style={{ width: '12%' }} />
|
||||
</colgroup>
|
||||
<thead style={{ background: 'transparent' }}>
|
||||
<tr style={{ background: 'transparent' }}>
|
||||
<th style={{ ...thStyle, padding: '0 0 12px 5px' }} />
|
||||
<SortTh col="company" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...thStyle, textAlign: 'left', paddingLeft: 5 }}>Company</SortTh>
|
||||
<SortTh col="contact" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={thStyle}>Contact</SortTh>
|
||||
<SortTh col="projects" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={thStyle}>Projects</SortTh>
|
||||
<SortTh col="open_tasks" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={thStyle}>Open Tasks</SortTh>
|
||||
<SortTh col="outstanding" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={thStyle}>Outstanding</SortTh>
|
||||
<SortTh col="paid" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={thStyle}>Paid</SortTh>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedHighlights.map(({ company, primaryContact, projectCount, openTaskCount, outstandingTotal = 0, paidTotal = 0 }) => (
|
||||
<tr key={company.id} style={{ background: 'transparent' }}>
|
||||
<td style={{ ...td(), padding: '3px 5px 7px' }}>
|
||||
<InitialPortrait name={company.name} />
|
||||
</td>
|
||||
<td style={{ ...td(), fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'left' }}>
|
||||
<button type="button" className="dashboard-inline-link" onClick={() => navigate(`/company/${company.id}`)}>{company.name}</button>
|
||||
</td>
|
||||
<td style={{ ...td(), fontSize: 12, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{primaryContact?.id ? (
|
||||
<button type="button" className="dashboard-inline-link" onClick={() => navigate(profilePath(primaryContact.id, primaryContact.role))}>{primaryContact.name || 'Profile'}</button>
|
||||
) : (primaryContact?.name || '—')}
|
||||
</td>
|
||||
<td style={{ ...td(), fontSize: 12, color: 'var(--text-primary)' }}>{projectCount}</td>
|
||||
<td style={{ ...td(), fontSize: 12, color: 'var(--text-primary)' }}>{openTaskCount || 0}</td>
|
||||
<td style={{ ...td(), fontSize: 12, color: '#F5A523' }}>{fmt(outstandingTotal)}</td>
|
||||
<td style={{ ...td(), fontSize: 12, color: '#4ade80' }}>{fmt(paidTotal)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamPerformanceCard({ tasks, profiles, deliveries }) {
|
||||
function TeamPerformanceCard({ tasks, profiles, deliveries, cardHeight = null }) {
|
||||
const navigate = useNavigate();
|
||||
const profileMap = useMemo(() => {
|
||||
const m = new Map();
|
||||
(profiles || []).forEach(p => m.set(p.id, p));
|
||||
return m;
|
||||
}, [profiles]);
|
||||
const profileNameMap = useMemo(() => {
|
||||
const m = new Map();
|
||||
(profiles || []).forEach((profile) => {
|
||||
const key = String(profile?.name || '').trim().toLowerCase();
|
||||
if (key && !m.has(key)) m.set(key, profile);
|
||||
});
|
||||
return m;
|
||||
}, [profiles]);
|
||||
const doneStatuses = ['client_approved', 'invoiced', 'paid'];
|
||||
const toEST = (dateStr) => new Date(new Date(dateStr).toLocaleString('en-US', { timeZone: 'America/New_York' }));
|
||||
const monthsWithData = useMemo(() => new Set(
|
||||
@@ -556,9 +513,12 @@ function TeamPerformanceCard({ tasks, profiles, deliveries }) {
|
||||
}, []);
|
||||
const monthOpts = allMonthOpts.filter(o => monthsWithData.has(o.value));
|
||||
const [monthKey, setMonthKey] = useState(() => monthOpts[0]?.value || allMonthOpts[0].value);
|
||||
const effectiveMonthKey = monthOpts.some(option => option.value === monthKey)
|
||||
? monthKey
|
||||
: (monthOpts[0]?.value || allMonthOpts[0]?.value || '');
|
||||
|
||||
const { people, totalDone } = useMemo(() => {
|
||||
const [year, month] = monthKey.split('-').map(Number);
|
||||
const [year, month] = effectiveMonthKey.split('-').map(Number);
|
||||
const map = new Map();
|
||||
(deliveries || []).forEach(d => {
|
||||
const task = d.submission?.task;
|
||||
@@ -567,35 +527,50 @@ function TeamPerformanceCard({ tasks, profiles, deliveries }) {
|
||||
if (dt.getFullYear() !== year || dt.getMonth() + 1 !== month) return;
|
||||
const name = d.sent_by || task.assigned_name;
|
||||
if (!name) return;
|
||||
const entry = map.get(name) || { name, id: task.assigned_to || null, newCount: 0, revCount: 0 };
|
||||
const matchedProfile = (task.assigned_to && profileMap.get(task.assigned_to)) || profileNameMap.get(String(name).trim().toLowerCase()) || null;
|
||||
const entry = map.get(name) || {
|
||||
name,
|
||||
id: matchedProfile?.id || task.assigned_to || null,
|
||||
avatar_url: matchedProfile?.avatar_url || '',
|
||||
newCount: 0,
|
||||
revCount: 0,
|
||||
};
|
||||
if ((d.version_number || 0) === 0) entry.newCount += 1;
|
||||
else entry.revCount += 1;
|
||||
map.set(name, entry);
|
||||
});
|
||||
const people = [...map.values()].map(p => ({ ...p, done: p.newCount + p.revCount })).sort((a, b) => b.done - a.done);
|
||||
return { people, totalDone: people.reduce((s, p) => s + p.done, 0) };
|
||||
}, [deliveries, monthKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [deliveries, effectiveMonthKey, profileMap, profileNameMap]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column', height: cardHeight || 'auto', minHeight: cardHeight ? 0 : 280 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Team Performance</span>
|
||||
<div style={{ position: 'relative', display: 'inline-flex', alignItems: 'center' }}>
|
||||
<select value={monthKey} onChange={e => setMonthKey(e.target.value)} style={{ fontSize: 11, color: 'var(--text-muted)', background: 'transparent', border: 'none', boxShadow: 'none', cursor: 'pointer', outline: 'none', fontFamily: 'inherit', appearance: 'none', WebkitAppearance: 'none', MozAppearance: 'none', paddingRight: 14 }}>
|
||||
<select value={effectiveMonthKey} onChange={e => setMonthKey(e.target.value)} style={{ fontSize: 11, color: 'var(--text-muted)', background: 'transparent', border: 'none', boxShadow: 'none', cursor: 'pointer', outline: 'none', fontFamily: 'inherit', appearance: 'none', WebkitAppearance: 'none', MozAppearance: 'none', paddingRight: 14 }}>
|
||||
{monthOpts.map(o => <option key={o.value} value={o.value} style={{ background: '#1a1a1a' }}>{o.label}</option>)}
|
||||
</select>
|
||||
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" style={{ position: 'absolute', right: 0, pointerEvents: 'none' }} stroke="rgba(255,255,255,0.5)" strokeWidth="1.5" strokeLinecap="round"><polyline points="1,2 4,6 7,2"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
{people.length === 0 ? (
|
||||
<div className="card-empty-center">No completed tasks this month</div>
|
||||
<div className="card-empty-center" style={{ flex: 1 }}>No completed tasks this month</div>
|
||||
) : (
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
<div className="table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||
{people.slice(0, 5).map((p, i) => {
|
||||
const pct = totalDone > 0 ? Math.round((p.done / totalDone) * 100) : 0;
|
||||
return (
|
||||
<div key={p.name} style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: i > 0 ? 10 : 0 }}>
|
||||
{(() => { const prof = p.id ? profileMap.get(p.id) : null; return prof?.avatar_url ? <div style={{ width: 27, height: 27, borderRadius: '50%', flexShrink: 0, overflow: 'hidden' }}><img src={prof.avatar_url} alt={p.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /></div> : <Avatar name={p.name} />; })()}
|
||||
<ProfileAvatar
|
||||
profileId={p.id}
|
||||
name={p.name}
|
||||
avatarUrl={p.avatar_url}
|
||||
profilesById={profileMap}
|
||||
profilesByName={profileNameMap}
|
||||
size={27}
|
||||
fontSize={12}
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
{p.id ? <button type="button" className="dashboard-inline-link" style={{ fontSize: 13, fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} onClick={() => navigate(`/profile/${p.id}`)}>{p.name}</button> : <span style={{ fontSize: 13, color: 'var(--text-primary)', fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>}
|
||||
@@ -628,26 +603,40 @@ export default function TeamDashboard() {
|
||||
const isClient = currentUser?.role === 'client';
|
||||
const isExternal = currentUser?.role === 'external';
|
||||
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const refresh = useCallback(() => setRefreshKey(k => k + 1), []);
|
||||
useRefetchOnFocus(refresh);
|
||||
useRealtimeSubscription(['tasks', 'projects', 'submissions', 'activity_log', 'profiles'], refresh);
|
||||
const { refreshKey } = useLiveRefresh(['tasks', 'projects', 'submissions', 'activity_log', 'profiles']);
|
||||
|
||||
const cached = isTeam ? readPageCache(CACHE_KEY, 5 * 60_000) : null;
|
||||
|
||||
const [tasks, setTasks] = useState(() => cached?.tasks || []);
|
||||
const [projects, setProjects] = useState(() => cached?.projects || []);
|
||||
const [submissions, setSubmissions] = useState(() => cached?.submissions || []);
|
||||
const [allCompanies, setAllCompanies] = useState(() => cached?.companies || []);
|
||||
const [clientProfiles, setClientProfiles] = useState(() => cached?.clientProfiles || []);
|
||||
const [companyMemberships, setCompanyMemberships] = useState(() => cached?.companyMemberships || []);
|
||||
const [activityLog, setActivityLog] = useState(() => cached?.activityLog || []);
|
||||
const [allProfiles, setAllProfiles] = useState(() => cached?.allProfiles || []);
|
||||
const [teamInvoices, setTeamInvoices] = useState(() => cached?.teamInvoices || []);
|
||||
const [teamExpenses, setTeamExpenses] = useState([]);
|
||||
const [teamSubcontractorPOs, setTeamSubcontractorPOs] = useState([]);
|
||||
const [teamSubInvoices, setTeamSubInvoices] = useState([]);
|
||||
const [myInvoices, setMyInvoices] = useState([]);
|
||||
const [perfDeliveries, setPerfDeliveries] = useState([]);
|
||||
const [loading, setLoading] = useState(!cached);
|
||||
const bottomCardsRowRef = useRef(null);
|
||||
const [bottomCardsHeight, setBottomCardsHeight] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
function updateBottomCardsHeight() {
|
||||
const row = bottomCardsRowRef.current;
|
||||
if (!row) return;
|
||||
const rect = row.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0;
|
||||
const available = Math.floor(viewportHeight - rect.top - 24);
|
||||
const nextHeight = available > 0 ? `${available}px` : null;
|
||||
setBottomCardsHeight(prev => (prev === nextHeight ? prev : nextHeight));
|
||||
}
|
||||
|
||||
updateBottomCardsHeight();
|
||||
window.addEventListener('resize', updateBottomCardsHeight);
|
||||
return () => window.removeEventListener('resize', updateBottomCardsHeight);
|
||||
}, [loading, isTeam, isClient, isExternal]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -658,37 +647,16 @@ export default function TeamDashboard() {
|
||||
cutoff.setMonth(cutoff.getMonth() - CUTOFF_MONTHS);
|
||||
const cutoffStr = cutoff.toISOString();
|
||||
|
||||
let scopedTaskIds = null;
|
||||
let scopedProjectIds = null;
|
||||
let scopedCompanyIds = null;
|
||||
|
||||
if (!isTeam && isClient) {
|
||||
const companyIds = [...new Set([...(currentUser?.companies?.map(c => c.company?.id || c.id) || []), ...(currentUser?.company_id ? [currentUser.company_id] : [])])].filter(Boolean);
|
||||
scopedCompanyIds = companyIds;
|
||||
if (companyIds.length > 0) {
|
||||
const { data: projRows } = await supabase.from('projects').select('id, tasks(id)').in('company_id', companyIds);
|
||||
scopedProjectIds = (projRows || []).map(p => p.id).filter(Boolean);
|
||||
scopedTaskIds = (projRows || []).flatMap(p => (p.tasks || []).map(t => t.id)).filter(Boolean);
|
||||
} else {
|
||||
scopedProjectIds = [];
|
||||
scopedTaskIds = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (!isTeam && isExternal) {
|
||||
const uid = currentUser?.id;
|
||||
const { data: memberData } = await supabase.from('project_members').select('project_id').eq('profile_id', uid);
|
||||
scopedProjectIds = (memberData || []).map(m => m.project_id).filter(Boolean);
|
||||
if ((scopedProjectIds || []).length > 0) {
|
||||
const { data: projectTasks } = await supabase.from('tasks').select('id').in('project_id', scopedProjectIds);
|
||||
scopedTaskIds = (projectTasks || []).map(row => row.id).filter(Boolean);
|
||||
} else {
|
||||
scopedTaskIds = [];
|
||||
}
|
||||
}
|
||||
const {
|
||||
scopedTaskIds,
|
||||
scopedProjectIds,
|
||||
scopedCompanyIds,
|
||||
} = isTeam
|
||||
? { scopedTaskIds: null, scopedProjectIds: null, scopedCompanyIds: null }
|
||||
: await resolveScopedWorkIds(currentUser, { isClient, isExternal });
|
||||
|
||||
const tasksQuery = supabase.from('tasks')
|
||||
.select('id, title, status, current_version, project_id, assigned_name, assigned_to, completed_at, assignee:profiles!assigned_to(avatar_url)')
|
||||
.select('id, title, status, current_version, project_id, assigned_name, assigned_to, completed_at, project:projects(name), assignee:profiles!assigned_to(avatar_url)')
|
||||
.gte('submitted_at', cutoffStr)
|
||||
.order('submitted_at', { ascending: false });
|
||||
if (isClient) {
|
||||
@@ -724,10 +692,10 @@ export default function TeamDashboard() {
|
||||
}
|
||||
|
||||
const invoicesPromise = isTeam
|
||||
? supabase.from('invoices').select('total, status, company_id, created_at').in('status', ['sent', 'paid'])
|
||||
? supabase.from('invoices').select('total, stripe_fee, status, company_id, created_at').in('status', ['sent', 'paid'])
|
||||
: isClient
|
||||
? ((scopedCompanyIds || []).length > 0
|
||||
? supabase.from('invoices').select('total, status, company_id, created_at').in('company_id', scopedCompanyIds).in('status', ['sent', 'paid'])
|
||||
? supabase.from('invoices').select('total, stripe_fee, status, company_id, created_at').in('company_id', scopedCompanyIds).in('status', ['sent', 'paid'])
|
||||
: Promise.resolve({ data: [] }))
|
||||
: supabase.from('subcontractor_invoices').select('total, status').eq('profile_id', currentUser?.id).in('status', ['submitted', 'approved', 'paid']);
|
||||
|
||||
@@ -735,17 +703,25 @@ export default function TeamDashboard() {
|
||||
? supabase.from('expenses').select('amount')
|
||||
: Promise.resolve({ data: [] });
|
||||
|
||||
const [{ data: t }, { data: p }, { data: subs }, { data: profiles }, { data: activity }, { data: cos }, { data: memRows }, { data: invs }, { data: exps }, { data: delivs }] = await withTimeout(Promise.all([
|
||||
const subcontractorPOsPromise = isTeam
|
||||
? supabase.from('subcontractor_payments').select('amount, status, paid_at, date')
|
||||
: Promise.resolve({ data: [] });
|
||||
|
||||
const subcontractorInvoicesPromise = isTeam
|
||||
? supabase.from('subcontractor_invoices').select('status, paid_at, items:subcontractor_invoice_items(unit_price, quantity)')
|
||||
: Promise.resolve({ data: [] });
|
||||
|
||||
const [{ data: t }, { data: p }, { data: subs }, { data: profiles }, { data: activity }, { data: invs }, { data: exps }, { data: subcontractorPOs }, { data: subcontractorInvoices }, { data: delivs }] = await withTimeout(Promise.all([
|
||||
tasksQuery,
|
||||
projectsQuery,
|
||||
submissionsQuery,
|
||||
supabase.from('profiles').select('id, role, name, email, company_id, brand_book_rate, avatar_url'),
|
||||
supabase.from('activity_log').select('id, created_at, actor_id, actor_name, action, task_id, task_title, project_name, project_id').order('created_at', { ascending: false }).limit(isTeam ? 20 : 50),
|
||||
supabase.from('companies').select('id, name').order('name'),
|
||||
supabase.from('company_members').select('company_id, profile_id'),
|
||||
invoicesPromise,
|
||||
expensesPromise,
|
||||
supabase.from('deliveries').select('sent_by, sent_at, version_number, submission:submissions!inner(task:tasks!inner(assigned_to, assigned_name))').gte('sent_at', cutoffStr),
|
||||
subcontractorPOsPromise,
|
||||
subcontractorInvoicesPromise,
|
||||
supabase.from('deliveries').select('sent_by, sent_at, version_number, submission:submissions!inner(task_id, task:tasks!inner(assigned_to, assigned_name))').gte('sent_at', cutoffStr),
|
||||
]), 30000, 'Dashboard load');
|
||||
if (cancelled) return;
|
||||
|
||||
@@ -754,14 +730,14 @@ export default function TeamDashboard() {
|
||||
const roleById = new Map((profiles || []).map(pr => [pr.id, pr.role]));
|
||||
const roleByName = new Map((profiles || []).map(pr => [pr.name, pr.role]));
|
||||
const tasksWithDeadlines = (t || []).map(task => ({ ...task, deadline: getDeadlineSourceSubmission(task, subs)?.deadline || null, assignee_role: roleById.get(task.assigned_to) || null }));
|
||||
const subsWithRole = (subs || []).map(sub => ({ ...sub, submitter_role: roleById.get(sub.submitted_by) || null, delivery_sender_role: roleByName.get(sub.delivery?.sent_by) || null }));
|
||||
const subsWithRole = mergeSubmissionDisplayNames(
|
||||
(subs || []).map(sub => ({ ...sub, submitter_role: roleById.get(sub.submitted_by) || null, delivery_sender_role: roleByName.get(sub.delivery?.sent_by) || null })),
|
||||
profiles || []
|
||||
);
|
||||
setTasks(tasksWithDeadlines);
|
||||
setProjects(p || []);
|
||||
setSubmissions(subsWithRole);
|
||||
setClientProfiles((profiles || []).filter(pr => pr.role === 'client'));
|
||||
setAllProfiles(profiles || []);
|
||||
setAllCompanies(cos || []);
|
||||
setCompanyMemberships(memRows || []);
|
||||
const scopedActivity = isTeam
|
||||
? (activity || [])
|
||||
: (activity || []).filter((entry) => {
|
||||
@@ -773,16 +749,19 @@ export default function TeamDashboard() {
|
||||
setTeamInvoices(isTeam ? (invs || []) : []);
|
||||
setMyInvoices(!isTeam ? (invs || []) : []);
|
||||
setTeamExpenses(exps || []);
|
||||
setPerfDeliveries(delivs || []);
|
||||
setTeamSubcontractorPOs(subcontractorPOs || []);
|
||||
setTeamSubInvoices(subcontractorInvoices || []);
|
||||
const scopedDeliveryTaskIds = new Set(scopedTaskIds || []);
|
||||
const scopedDeliveries = isTeam
|
||||
? (delivs || [])
|
||||
: (delivs || []).filter(delivery => scopedDeliveryTaskIds.has(delivery.submission?.task_id));
|
||||
setPerfDeliveries(scopedDeliveries);
|
||||
|
||||
if (isTeam) {
|
||||
writePageCache(CACHE_KEY, {
|
||||
tasks: tasksWithDeadlines,
|
||||
projects: p || [],
|
||||
submissions: subsWithRole,
|
||||
clientProfiles: (profiles || []).filter(pr => pr.role === 'client'),
|
||||
companies: cos || [],
|
||||
companyMemberships: memRows || [],
|
||||
activityLog: activity || [],
|
||||
teamInvoices: invs || [],
|
||||
allProfiles: profiles || [],
|
||||
@@ -797,12 +776,6 @@ export default function TeamDashboard() {
|
||||
return () => { cancelled = true; };
|
||||
}, [isTeam, isClient, isExternal, currentUser?.id, currentUser?.company_id, currentUser?.companies, refreshKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const teamHighlights = useMemo(() =>
|
||||
isTeam ? buildClientHighlights(allCompanies, projects, tasks, clientProfiles, companyMemberships, teamInvoices)
|
||||
.sort((a, b) => b.openTaskCount - a.openTaskCount || b.projectCount - a.projectCount || a.company.name.localeCompare(b.company.name))
|
||||
.slice(0, 5) : [],
|
||||
[isTeam, allCompanies, projects, tasks, clientProfiles, companyMemberships, teamInvoices]);
|
||||
|
||||
const activityEvents = useMemo(() =>
|
||||
(activityLog || []).map(e => ({
|
||||
time: new Date(e.created_at),
|
||||
@@ -817,7 +790,7 @@ export default function TeamDashboard() {
|
||||
})).filter(e => !isNaN(e.time)).slice(0, 10),
|
||||
[activityLog]);
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
if (loading) return <Layout><PageLoader /></Layout>;
|
||||
|
||||
const doneStatuses = ['client_approved', 'invoiced', 'paid'];
|
||||
const inProgressTasks = tasks.filter(t => t.status === 'in_progress');
|
||||
@@ -839,7 +812,18 @@ export default function TeamDashboard() {
|
||||
});
|
||||
const dashRevenue = teamInvoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total || 0), 0);
|
||||
const dashOutstanding = teamInvoices.filter(i => i.status === 'sent').reduce((s, i) => s + Number(i.total || 0), 0);
|
||||
const dashExpensesTotal = teamExpenses.reduce((s, e) => s + Number(e.amount || 0), 0);
|
||||
const dashOpsExpensesTotal = teamExpenses.reduce((s, e) => s + Number(e.amount || 0), 0);
|
||||
const dashNetReceived = teamInvoices
|
||||
.filter(i => i.status === 'paid')
|
||||
.reduce((s, i) => s + Number(i.total || 0) - Number(i.stripe_fee || 0), 0);
|
||||
const dashPaidSubcontractorPOsTotal = teamSubcontractorPOs
|
||||
.filter(po => po.status === 'paid')
|
||||
.reduce((s, po) => s + Number(po.amount || 0), 0);
|
||||
const dashPaidSubInvoicesTotal = teamSubInvoices
|
||||
.filter(inv => inv.status === 'paid')
|
||||
.reduce((s, inv) => s + (inv.items || []).reduce((a, item) => a + Number(item.unit_price || 0) * Number(item.quantity || 1), 0), 0);
|
||||
const dashSubcontractorExpensesTotal = dashPaidSubcontractorPOsTotal + dashPaidSubInvoicesTotal;
|
||||
const dashExpensesTotal = dashOpsExpensesTotal + dashSubcontractorExpensesTotal;
|
||||
const revenueByMonth = Array.from({ length: 4 }, (_, i) => {
|
||||
const now = new Date();
|
||||
const start = new Date(now.getFullYear(), now.getMonth() - (3 - i), 1);
|
||||
@@ -851,7 +835,7 @@ export default function TeamDashboard() {
|
||||
const myOutstanding = myInvoices.filter(i => i.status !== 'paid').reduce((s, i) => s + Number(i.total || 0), 0);
|
||||
|
||||
const card3 = isTeam
|
||||
? { label: 'Net Profit', value: fmtMoney(dashRevenue - dashExpensesTotal), sub: `${fmtMoney(dashExpensesTotal)} expenses`, iconBg: 'rgba(74,222,128,0.15)', iconColor: '#4ade80', iconPath: DASH_ICONS.profit }
|
||||
? { label: 'Net Profit', value: fmtMoney(dashNetReceived - dashExpensesTotal), sub: `${fmtMoney(dashExpensesTotal)} expenses + sub costs`, iconBg: 'rgba(74,222,128,0.15)', iconColor: '#4ade80', iconPath: DASH_ICONS.profit }
|
||||
: isClient
|
||||
? { label: 'Outstanding', value: fmtMoney(myOutstanding), sub: 'invoices due', iconBg: 'rgba(245,165,35,0.15)', iconColor: '#F5A523', iconPath: DASH_ICONS.revenue }
|
||||
: { label: 'Pending', value: fmtMoney(myOutstanding), sub: 'awaiting payment', iconBg: 'rgba(245,165,35,0.15)', iconColor: '#F5A523', iconPath: DASH_ICONS.revenue };
|
||||
@@ -875,15 +859,10 @@ export default function TeamDashboard() {
|
||||
<TasksInProgressCard tasks={inProgressTasks} />
|
||||
<MiniCalendar items={calendarItems} />
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginTop: 24 }}>
|
||||
<HotItemsCard submissions={submissions} tasks={tasks} isClient={isClient} />
|
||||
<TeamPerformanceCard tasks={tasks} profiles={allProfiles} deliveries={perfDeliveries} />
|
||||
<div ref={bottomCardsRowRef} style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginTop: 24 }}>
|
||||
<HotItemsCard submissions={submissions} tasks={tasks} isClient={isClient} isExternal={isExternal} currentUserId={currentUser?.id || null} cardHeight={bottomCardsHeight} />
|
||||
<TeamPerformanceCard tasks={tasks} profiles={allProfiles} deliveries={perfDeliveries} cardHeight={bottomCardsHeight} />
|
||||
</div>
|
||||
{isTeam && (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<ClientHighlightCard highlights={teamHighlights} />
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import Layout from '../../components/Layout';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { formatShortDateTime } from '../../lib/dates';
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
@@ -17,17 +18,6 @@ function sortEntries(entries) {
|
||||
return [...entries].sort((a, b) => a.service_name.localeCompare(b.service_name, undefined, { sensitivity: 'base' }));
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return 'Unknown';
|
||||
return new Date(value).toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeUrl(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) return '';
|
||||
@@ -253,7 +243,7 @@ export default function FourgePasswords() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Layout loading={loading}>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">Fourge Passwords</div>
|
||||
@@ -294,10 +284,7 @@ export default function FourgePasswords() {
|
||||
{loading ? (
|
||||
<div style={{ color: 'var(--text-muted)' }}>Loading passwords...</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="empty-state" style={{ padding: '28px 20px' }}>
|
||||
<h3 style={{ marginBottom: 8 }}>No passwords yet</h3>
|
||||
<p>Add encrypted password entries for internal services and team access.</p>
|
||||
</div>
|
||||
<div className="card-empty-center" style={{ minHeight: 120 }}>No passwords</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
{entries.map(entry => (
|
||||
@@ -316,7 +303,7 @@ export default function FourgePasswords() {
|
||||
<div>
|
||||
<div style={{ fontSize: 15, fontWeight: 400, color: 'var(--text-primary)' }}>{entry.service_name}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 4 }}>
|
||||
Updated {formatDate(entry.updated_at || entry.created_at)}
|
||||
Updated {formatShortDateTime(entry.updated_at || entry.created_at, 'Unknown')}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate, useLocation, Link } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import PageLoader from '../../components/PageLoader';
|
||||
import LoadingButton from '../../components/LoadingButton';
|
||||
import SortTh from '../../components/SortTh';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
@@ -9,15 +10,20 @@ import { generateInvoicePDF, generateReceiptPDF } from '../../lib/invoice';
|
||||
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
|
||||
import { withTimeout } from '../../lib/withTimeout';
|
||||
import { useSortable } from '../../hooks/useSortable';
|
||||
import { popupOverlayStyle, popupSurfaceStyle } from '../../lib/popupStyles';
|
||||
|
||||
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
|
||||
|
||||
export default function InvoiceDetail() {
|
||||
const { id } = useParams();
|
||||
export function TeamInvoiceDetailPanel({
|
||||
invoiceId,
|
||||
initialInvoice = null,
|
||||
embedded = false,
|
||||
onClose,
|
||||
onInvoiceUpdated,
|
||||
onInvoiceDeleted,
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const { state } = useLocation();
|
||||
|
||||
const [invoice, setInvoice] = useState(state?.invoice || null);
|
||||
const [invoice, setInvoice] = useState(initialInvoice || null);
|
||||
const [company, setCompany] = useState(null);
|
||||
const [companies, setCompanies] = useState([]);
|
||||
const [items, setItems] = useState([]);
|
||||
@@ -32,14 +38,14 @@ export default function InvoiceDetail() {
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const { data: inv } = await supabase.from('invoices').select('*').eq('id', id).single();
|
||||
const { data: inv } = await supabase.from('invoices').select('*').eq('id', invoiceId).single();
|
||||
if (!inv) return;
|
||||
setInvoice(inv);
|
||||
|
||||
const [{ data: co }, { data: companyList }, { data: its }] = await Promise.all([
|
||||
supabase.from('companies').select('*').eq('id', inv.company_id).single(),
|
||||
supabase.from('companies').select('*').order('name'),
|
||||
supabase.from('invoice_items').select('*').eq('invoice_id', id).order('created_at'),
|
||||
supabase.from('invoice_items').select('*').eq('invoice_id', invoiceId).order('created_at'),
|
||||
]);
|
||||
const defaultEmail = inv.invoice_email || await getDefaultInvoiceEmail(inv.company_id, co);
|
||||
setCompany(co);
|
||||
@@ -53,23 +59,47 @@ export default function InvoiceDetail() {
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, [id]);
|
||||
}, [invoiceId]);
|
||||
|
||||
const syncInvoiceState = (updater) => {
|
||||
setInvoice((current) => {
|
||||
const next = typeof updater === 'function' ? updater(current) : updater;
|
||||
onInvoiceUpdated?.(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const updateStatus = async (status) => {
|
||||
setSaving(true);
|
||||
const updates = { status };
|
||||
if (status === 'paid' && !invoice.paid_at) updates.paid_at = new Date().toISOString();
|
||||
if (status !== 'paid') updates.paid_at = null;
|
||||
const { error } = await supabase.from('invoices').update(updates).eq('id', id);
|
||||
const { error } = await supabase.from('invoices').update(updates).eq('id', invoiceId);
|
||||
if (!error) {
|
||||
setInvoice(i => ({ ...i, ...updates }));
|
||||
syncInvoiceState(i => ({ ...i, ...updates }));
|
||||
// Sync task statuses along invoice lifecycle
|
||||
const { data: freshItems } = await supabase.from('invoice_items').select('task_id, submission_id').eq('invoice_id', id);
|
||||
const { data: freshItems } = await supabase.from('invoice_items').select('task_id, submission_id').eq('invoice_id', invoiceId);
|
||||
const taskIds = (freshItems || []).filter(i => i.task_id && !i.submission_id).map(i => i.task_id);
|
||||
if (taskIds.length > 0) {
|
||||
const newTaskStatus = status === 'paid' ? 'paid' : status === 'sent' ? 'invoiced' : 'client_approved';
|
||||
await supabase.from('tasks').update({ status: newTaskStatus }).in('id', taskIds);
|
||||
}
|
||||
if (status === 'paid') {
|
||||
try {
|
||||
const contactEmail = invoice.invoice_email || await getDefaultInvoiceEmail(invoice.company_id, company);
|
||||
if (contactEmail) {
|
||||
const paidDate = new Date(updates.paid_at || new Date().toISOString()).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
await sendEmail('receipt_sent', [contactEmail], {
|
||||
invoiceNumber: invoice.invoice_number,
|
||||
billTo: invoice.bill_to || company?.name,
|
||||
total: `$${Number(invoice.total).toFixed(2)}`,
|
||||
paidDate,
|
||||
});
|
||||
}
|
||||
} catch (emailErr) {
|
||||
console.error('Failed to send paid invoice email:', emailErr);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
alert('Failed to update status.');
|
||||
}
|
||||
@@ -104,12 +134,12 @@ export default function InvoiceDetail() {
|
||||
const contactEmail = getEmailRecipient();
|
||||
if (!contactEmail) throw new Error('Enter an email recipient before sending.');
|
||||
const { error } = await withTimeout(
|
||||
supabase.from('invoices').update({ invoice_email: contactEmail }).eq('id', id),
|
||||
supabase.from('invoices').update({ invoice_email: contactEmail }).eq('id', invoiceId),
|
||||
12000,
|
||||
'Saving invoice email'
|
||||
);
|
||||
if (error) throw error;
|
||||
setInvoice(inv => ({ ...inv, invoice_email: contactEmail }));
|
||||
syncInvoiceState(inv => ({ ...inv, invoice_email: contactEmail }));
|
||||
return contactEmail;
|
||||
};
|
||||
|
||||
@@ -167,12 +197,12 @@ export default function InvoiceDetail() {
|
||||
|
||||
const updates = { status: 'sent' };
|
||||
const { error } = await withTimeout(
|
||||
supabase.from('invoices').update(updates).eq('id', id),
|
||||
supabase.from('invoices').update(updates).eq('id', invoiceId),
|
||||
12000,
|
||||
'Updating invoice status'
|
||||
);
|
||||
if (error) throw error;
|
||||
setInvoice(i => ({ ...i, ...updates }));
|
||||
syncInvoiceState(i => ({ ...i, ...updates }));
|
||||
alert(`Invoice email sent successfully.${attachmentWarning || ''}`);
|
||||
} catch (err) {
|
||||
console.error('Failed to finalize and send invoice:', err);
|
||||
@@ -202,16 +232,22 @@ export default function InvoiceDetail() {
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (invoice?.status === 'paid') {
|
||||
alert('Paid invoices cannot be deleted.');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const { data: freshItems } = await supabase.from('invoice_items').select('task_id, submission_id').eq('invoice_id', id);
|
||||
const { data: freshItems } = await supabase.from('invoice_items').select('task_id, submission_id').eq('invoice_id', invoiceId);
|
||||
const taskIds = (freshItems || []).filter(i => i.task_id && !i.submission_id).map(i => i.task_id);
|
||||
if (taskIds.length > 0) await supabase.from('tasks').update({ invoiced: false, status: 'client_approved' }).in('id', taskIds);
|
||||
const submissionIds = (freshItems || []).filter(i => i.submission_id).map(i => i.submission_id);
|
||||
if (submissionIds.length > 0) await supabase.from('submissions').update({ invoiced: false }).in('id', submissionIds);
|
||||
const { error } = await supabase.from('invoices').delete().eq('id', id);
|
||||
const { error } = await supabase.from('invoices').delete().eq('id', invoiceId);
|
||||
if (error) throw error;
|
||||
navigate('/invoices');
|
||||
onInvoiceDeleted?.(invoiceId);
|
||||
if (embedded) onClose?.();
|
||||
else navigate('/finances');
|
||||
} catch {
|
||||
alert('Failed to delete invoice. Please try again.');
|
||||
setSaving(false);
|
||||
@@ -279,8 +315,8 @@ export default function InvoiceDetail() {
|
||||
await supabase.from('invoices').update({
|
||||
invoice_date: dateForm.invoice_date,
|
||||
due_date: dateForm.due_date,
|
||||
}).eq('id', id);
|
||||
setInvoice(i => ({ ...i, invoice_date: dateForm.invoice_date, due_date: dateForm.due_date }));
|
||||
}).eq('id', invoiceId);
|
||||
syncInvoiceState(i => ({ ...i, invoice_date: dateForm.invoice_date, due_date: dateForm.due_date }));
|
||||
setEditingDates(false);
|
||||
setSaving(false);
|
||||
};
|
||||
@@ -288,8 +324,8 @@ export default function InvoiceDetail() {
|
||||
const handleEmailBlur = async () => {
|
||||
const nextEmail = getEmailRecipient();
|
||||
if ((invoice.invoice_email || '') === nextEmail) return;
|
||||
const { error } = await supabase.from('invoices').update({ invoice_email: nextEmail || null }).eq('id', id);
|
||||
if (!error) setInvoice(inv => ({ ...inv, invoice_email: nextEmail || null }));
|
||||
const { error } = await supabase.from('invoices').update({ invoice_email: nextEmail || null }).eq('id', invoiceId);
|
||||
if (!error) syncInvoiceState(inv => ({ ...inv, invoice_email: nextEmail || null }));
|
||||
};
|
||||
|
||||
const handleCompanyChange = async (companyId) => {
|
||||
@@ -302,19 +338,37 @@ export default function InvoiceDetail() {
|
||||
company_id: companyId,
|
||||
bill_to: nextCompany.name,
|
||||
invoice_email: defaultEmail,
|
||||
}).eq('id', id);
|
||||
}).eq('id', invoiceId);
|
||||
setSaving(false);
|
||||
if (error) {
|
||||
alert('Failed to update invoice company. Please try again.');
|
||||
return;
|
||||
}
|
||||
setInvoice(inv => ({ ...inv, company_id: companyId, bill_to: nextCompany.name, invoice_email: defaultEmail }));
|
||||
syncInvoiceState(inv => ({ ...inv, company_id: companyId, bill_to: nextCompany.name, invoice_email: defaultEmail }));
|
||||
setCompany(nextCompany);
|
||||
setEmailRecipient(defaultEmail);
|
||||
};
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
if (!invoice) return <Layout><p>Invoice not found.</p></Layout>;
|
||||
if (loading) {
|
||||
if (embedded) {
|
||||
return (
|
||||
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<PageLoader />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <Layout><PageLoader /></Layout>;
|
||||
}
|
||||
if (!invoice) {
|
||||
if (embedded) {
|
||||
return (
|
||||
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-muted)' }}>
|
||||
Invoice not found.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <Layout><p>Invoice not found.</p></Layout>;
|
||||
}
|
||||
const sortedItems = sort(items, (item, key) => {
|
||||
if (key === 'type') return item.submission_id ? 'Revision' : 'New';
|
||||
if (key === 'description') return item.description || '';
|
||||
@@ -325,19 +379,18 @@ export default function InvoiceDetail() {
|
||||
});
|
||||
|
||||
const isOverdue = invoice.status !== 'paid' && new Date(invoice.due_date) < new Date();
|
||||
const detailContent = (
|
||||
<>
|
||||
{!embedded && <button className="back-link" onClick={() => navigate('/finances')}>← Back to Invoices</button>}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<button className="back-link" onClick={() => navigate('/invoices')}>← Back to Invoices</button>
|
||||
|
||||
<div className="page-header">
|
||||
<div className={embedded ? '' : 'page-header'} style={embedded ? { display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16, paddingBottom: 18, borderBottom: '1px solid var(--border)' } : undefined}>
|
||||
<div>
|
||||
<div className="page-title">{invoice.invoice_number}</div>
|
||||
<div className="page-subtitle">
|
||||
<div className={embedded ? '' : 'page-title'} style={embedded ? { fontSize: 18, fontWeight: 500, color: 'var(--text-primary)', letterSpacing: 0.2, lineHeight: 1.1 } : undefined}>{invoice.invoice_number}</div>
|
||||
<div className={embedded ? '' : 'page-subtitle'} style={embedded ? { marginTop: 6, fontSize: 13, color: 'var(--text-secondary)' } : undefined}>
|
||||
<Link to={`/company/${company?.id}`} style={{ color: 'var(--accent)' }}>{company?.name}</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="action-buttons">
|
||||
<div className="action-buttons" style={embedded ? { justifyContent: 'flex-end' } : undefined}>
|
||||
<StatusBadge
|
||||
status={statusColor[invoice.status] || 'not_started'}
|
||||
label={`${invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—'}${isOverdue ? ' · Overdue' : ''}`}
|
||||
@@ -355,7 +408,7 @@ export default function InvoiceDetail() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid-2" style={{ marginBottom: 24 }}>
|
||||
<div className="grid-2" style={{ marginTop: embedded ? 18 : 0, marginBottom: 24 }}>
|
||||
<div className="card">
|
||||
<div className="card-title">Bill To</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 400 }}>{invoice.bill_to || company?.name}</div>
|
||||
@@ -510,9 +563,33 @@ export default function InvoiceDetail() {
|
||||
</LoadingButton>
|
||||
</>
|
||||
)}
|
||||
<button className="btn-icon btn-icon-danger" title="Delete Invoice" onClick={handleDelete} disabled={saving}>✕</button>
|
||||
{invoice.status !== 'paid' && (
|
||||
<button className="btn-icon btn-icon-danger" title="Delete Invoice" onClick={handleDelete} disabled={saving}>✕</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
|
||||
if (embedded) {
|
||||
return (
|
||||
<div style={popupOverlayStyle} onClick={() => { if (!saving && !generating) onClose?.(); }}>
|
||||
<div
|
||||
style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0, overflowY: 'auto' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{detailContent}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <Layout>{detailContent}</Layout>;
|
||||
}
|
||||
|
||||
export default function InvoiceDetail() {
|
||||
const { id } = useParams();
|
||||
const { state } = useLocation();
|
||||
|
||||
return <TeamInvoiceDetailPanel invoiceId={id} initialInvoice={state?.invoice || null} />;
|
||||
}
|
||||
|
||||
+462
-85
@@ -7,6 +7,7 @@ import SortTh from '../../components/SortTh';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import { useSortable } from '../../hooks/useSortable';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
||||
import { exportCPAPackage, generateSubcontractorPOPDF, generateInvoicePDF } from '../../lib/invoice';
|
||||
import { withTimeout } from '../../lib/withTimeout';
|
||||
@@ -14,6 +15,9 @@ import LoadingButton from '../../components/LoadingButton';
|
||||
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
|
||||
import { popupOverlayStyle, popupSurfaceStyle } from '../../lib/popupStyles';
|
||||
import FileAttachment from '../../components/FileAttachment';
|
||||
import { isReviewShadowDescription } from '../../lib/taskVersions';
|
||||
import { getRevisionChargeQuantity, isCompletedVersionEligible, isInitialVersionEligible } from '../../lib/invoiceVersionRules';
|
||||
import { TeamInvoiceDetailPanel } from './TeamInvoiceDetail';
|
||||
|
||||
const CATEGORIES = ['Software', 'Contractor', 'Advertising', 'Office', 'Travel', 'Meals', 'Equipment', 'Other'];
|
||||
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
|
||||
@@ -40,12 +44,156 @@ const invoiceStatusBadgeLabel = (status) => {
|
||||
const RECEIPT_BUCKET = 'expense-receipts';
|
||||
const FIELD_LABEL_STYLE = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, display: 'block', marginBottom: 4 };
|
||||
const FIELD_INPUT_STYLE = { minHeight: 42, margin: 0 };
|
||||
const FINANCE_MODAL_INPUT_STYLE = {
|
||||
...FIELD_INPUT_STYLE,
|
||||
width: '100%',
|
||||
padding: '0 10px',
|
||||
fontSize: 13,
|
||||
color: 'var(--text-primary)',
|
||||
background: 'var(--card-bg-2)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 6,
|
||||
fontFamily: 'inherit',
|
||||
boxSizing: 'border-box',
|
||||
};
|
||||
const FINANCE_MODAL_TEXTAREA_STYLE = {
|
||||
...FINANCE_MODAL_INPUT_STYLE,
|
||||
minHeight: 72,
|
||||
padding: '10px',
|
||||
resize: 'vertical',
|
||||
};
|
||||
const FINANCE_MODAL_LINE_ITEM_INPUT_STYLE = {
|
||||
...FINANCE_MODAL_INPUT_STYLE,
|
||||
minHeight: 32,
|
||||
padding: '0 8px',
|
||||
};
|
||||
const FINANCE_MODAL_NUMBER_INPUT_STYLE = {
|
||||
...FINANCE_MODAL_LINE_ITEM_INPUT_STYLE,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
};
|
||||
const FINANCE_MODAL_AMOUNT_FIELD_STYLE = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
minHeight: 42,
|
||||
width: '100%',
|
||||
padding: '0 12px',
|
||||
background: 'var(--card-bg-2)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 6,
|
||||
boxSizing: 'border-box',
|
||||
};
|
||||
const FINANCE_MODAL_AMOUNT_PREFIX_STYLE = {
|
||||
flexShrink: 0,
|
||||
fontSize: 16,
|
||||
fontWeight: 500,
|
||||
lineHeight: 1,
|
||||
color: 'var(--text-secondary)',
|
||||
};
|
||||
const FINANCE_MODAL_AMOUNT_INPUT_STYLE = {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
background: 'transparent',
|
||||
boxShadow: 'none',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 22,
|
||||
fontWeight: 500,
|
||||
lineHeight: 1.1,
|
||||
textAlign: 'right',
|
||||
color: 'var(--text-primary)',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
appearance: 'textfield',
|
||||
};
|
||||
const FINANCE_MODAL_TOTAL_VALUE_STYLE = {
|
||||
fontSize: 24,
|
||||
fontWeight: 500,
|
||||
lineHeight: 1.1,
|
||||
color: 'var(--accent)',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
};
|
||||
|
||||
function parseSubInvoiceItemVersionNumber(item) {
|
||||
if (Number.isFinite(Number(item?.version_number))) return Number(item.version_number);
|
||||
const match = String(item?.description || '').match(/\bR(\d{2})\b/i);
|
||||
return match ? Number(match[1]) : 0;
|
||||
}
|
||||
|
||||
function subInvoiceItemVersionLabel(item) {
|
||||
return `R${String(parseSubInvoiceItemVersionNumber(item)).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function subInvoiceItemWorkLabel(item) {
|
||||
return parseSubInvoiceItemVersionNumber(item) > 0 ? 'Revision' : 'New';
|
||||
}
|
||||
|
||||
function cleanSubInvoiceItemDescription(item) {
|
||||
return String(item?.description || '—').replace(/\s*[–-]\s*R\d{2}\b/i, '').trim() || '—';
|
||||
}
|
||||
|
||||
function asArray(value) {
|
||||
if (Array.isArray(value)) return value;
|
||||
if (value == null) return [];
|
||||
return typeof value === 'object' ? [value] : [];
|
||||
}
|
||||
|
||||
async function fetchSubInvoiceTaskTypeMap(taskIds) {
|
||||
const uniqueTaskIds = [...new Set((taskIds || []).filter(Boolean))];
|
||||
if (uniqueTaskIds.length === 0) return {};
|
||||
const { data, error } = await supabase
|
||||
.from('tasks')
|
||||
.select('id, title, submissions(service_type, type)')
|
||||
.in('id', uniqueTaskIds);
|
||||
if (error) throw error;
|
||||
const next = {};
|
||||
for (const task of (data || [])) {
|
||||
const submissions = asArray(task.submissions);
|
||||
const initial = submissions.find((submission) => submission?.type === 'initial' && submission?.service_type);
|
||||
const fallback = submissions.find((submission) => submission?.service_type);
|
||||
next[task.id] = initial?.service_type || fallback?.service_type || task.title || 'Other';
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function CurrencyInput({ value, onChange, placeholder = '0.00', required = false, min = '0', step = '0.01' }) {
|
||||
return (
|
||||
<div style={FINANCE_MODAL_AMOUNT_FIELD_STYLE}>
|
||||
<span style={FINANCE_MODAL_AMOUNT_PREFIX_STYLE}>$</span>
|
||||
<input
|
||||
type="number"
|
||||
min={min}
|
||||
step={step}
|
||||
required={required}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
style={FINANCE_MODAL_AMOUNT_INPUT_STYLE}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const INV_TODAY = new Date().toISOString().split('T')[0];
|
||||
const INV_NET30 = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
const invNewItem = (description = '', unit_price = '', quantity = 1, task_id = null, submission_id = null) =>
|
||||
({ id: crypto.randomUUID(), description, unit_price, quantity, task_id, submission_id });
|
||||
|
||||
const invBuildNewItemDescription = (task) => {
|
||||
const projectName = task.project?.name || 'No Project';
|
||||
const taskTitle = task.title || task.service_type || 'Untitled';
|
||||
return `${projectName} • ${taskTitle}`;
|
||||
};
|
||||
|
||||
const invBuildRevisionItemDescription = (revision) => {
|
||||
const projectName = revision.task?.project?.name || 'No Project';
|
||||
const taskTitle = revision.task?.title || revision.service_type || 'Revision';
|
||||
const versionLabel = 'R' + String(revision.version_number || 0).padStart(2, '0');
|
||||
return `${projectName} • ${taskTitle} • Revision ${versionLabel}`;
|
||||
};
|
||||
|
||||
const blankExpense = () => ({
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
description: '',
|
||||
@@ -78,6 +226,7 @@ function FinancesChartTooltip({ active, payload, label, year }) {
|
||||
}
|
||||
|
||||
export default function Invoices() {
|
||||
const { currentUser } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const cached = readPageCache('team_invoices');
|
||||
@@ -126,6 +275,9 @@ export default function Invoices() {
|
||||
const [subInvoicesLoading, setSubInvoicesLoading] = useState(true);
|
||||
const [subInvoicesError, setSubInvoicesError] = useState('');
|
||||
const [markingPaid, setMarkingPaid] = useState('');
|
||||
const [viewingSubInvoice, setViewingSubInvoice] = useState(null);
|
||||
const [viewingSubInvoiceTaskTypeMap, setViewingSubInvoiceTaskTypeMap] = useState({});
|
||||
const [viewingInvoice, setViewingInvoice] = useState(null);
|
||||
const [expandedSubInvoiceId, setExpandedSubInvoiceId] = useState(null);
|
||||
|
||||
// ── New Invoice form ──────────────────────────────────────────────────────
|
||||
@@ -143,11 +295,30 @@ export default function Invoices() {
|
||||
const [invSaving, setInvSaving] = useState(false);
|
||||
const [invLoadingTasks, setInvLoadingTasks] = useState(false);
|
||||
const invDragItem = useRef(null);
|
||||
const pageLoading = loading || expensesLoading || subcontractorLoading || subInvoicesLoading;
|
||||
|
||||
useEffect(() => {
|
||||
supabase.from('companies').select('id, name, contact_email').order('name').then(({ data }) => setInvCompanies(data || []));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function loadSubInvoiceTaskTypes() {
|
||||
if (!viewingSubInvoice?.items?.length) {
|
||||
setViewingSubInvoiceTaskTypeMap({});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const map = await fetchSubInvoiceTaskTypeMap(asArray(viewingSubInvoice.items).map((item) => item.task_id));
|
||||
if (!cancelled) setViewingSubInvoiceTaskTypeMap(map);
|
||||
} catch {
|
||||
if (!cancelled) setViewingSubInvoiceTaskTypeMap({});
|
||||
}
|
||||
}
|
||||
loadSubInvoiceTaskTypes();
|
||||
return () => { cancelled = true; };
|
||||
}, [viewingSubInvoice]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!invCompanyId) { setInvUnbilledTasks([]); setInvUnbilledRevisions([]); setInvPriceList([]); setInvItems([invNewItem()]); setInvBillTo(''); setInvEmail(''); setInvRecipients([]); return; }
|
||||
const co = invCompanies.find(c => c.id === invCompanyId);
|
||||
@@ -163,12 +334,32 @@ export default function Invoices() {
|
||||
setInvEmail((users || [])[0]?.email || co?.contact_email || '');
|
||||
if (projects?.length > 0) {
|
||||
const pids = projects.map(p => p.id);
|
||||
const { data: tasks } = await supabase.from('tasks').select('*, project:projects(name), submissions(service_type, type, version_number)').in('project_id', pids).eq('invoiced', false).eq('status', 'client_approved');
|
||||
const tids = (tasks || []).map(t => t.id);
|
||||
const { data: revisions } = tids.length > 0 ? await supabase.from('submissions').select('*, task:tasks(id, title, project:projects(name), submissions(service_type, type))').eq('type', 'revision').or('revision_type.eq.client_revision,revision_type.is.null').eq('invoiced', false).in('task_id', tids) : { data: [] };
|
||||
setInvUnbilledTasks((tasks || []).map(t => { const ini = (t.submissions || []).find(s => s.type === 'initial') || t.submissions?.[0]; return { ...t, service_type: ini?.service_type || t.title }; }));
|
||||
const { data: companyTasks } = await supabase
|
||||
.from('tasks')
|
||||
.select('*, project:projects(name), submissions(service_type, type, version_number)')
|
||||
.in('project_id', pids)
|
||||
.in('status', ['not_started', 'in_progress', 'on_hold', 'client_review', 'client_approved', 'invoiced', 'paid']);
|
||||
const revisionTaskIds = (companyTasks || []).map(t => t.id).filter(Boolean);
|
||||
const { data: revisions } = revisionTaskIds.length > 0
|
||||
? await supabase
|
||||
.from('submissions')
|
||||
.select('*, task:tasks(id, title, status, current_version, project:projects(name), submissions(service_type, type))')
|
||||
.eq('type', 'revision')
|
||||
.eq('invoiced', false)
|
||||
.in('task_id', revisionTaskIds)
|
||||
.order('submitted_at', { ascending: false })
|
||||
: { data: [] };
|
||||
const uninvoicedTasks = (companyTasks || []).filter(isInitialVersionEligible);
|
||||
setInvUnbilledTasks(uninvoicedTasks.map(t => {
|
||||
const ini = (t.submissions || []).find(s => s.type === 'initial') || t.submissions?.[0];
|
||||
return { ...t, service_type: ini?.service_type || t.title };
|
||||
}));
|
||||
const revMap = new Map();
|
||||
for (const r of (revisions || [])) { const k = `${r.task_id}:${r.version_number}`; if (!revMap.has(k)) revMap.set(k, r); }
|
||||
for (const r of (revisions || []).filter(r => !isReviewShadowDescription(r.description))) {
|
||||
if (!isCompletedVersionEligible(r.task, r.version_number)) continue;
|
||||
const k = `${r.task_id}:${r.version_number}`;
|
||||
if (!revMap.has(k)) revMap.set(k, r);
|
||||
}
|
||||
setInvUnbilledRevisions([...revMap.values()]);
|
||||
} else { setInvUnbilledTasks([]); setInvUnbilledRevisions([]); }
|
||||
setInvLoadingTasks(false);
|
||||
@@ -177,18 +368,17 @@ export default function Invoices() {
|
||||
|
||||
const invAddTask = (task) => {
|
||||
const price = invPriceList.find(p => p.service_type === task.service_type && p.price_type === 'new');
|
||||
const desc = `${task.project?.name || 'No Project'} • ${task.title || task.service_type || 'Untitled'}`;
|
||||
const desc = invBuildNewItemDescription(task);
|
||||
setInvItems(prev => prev.length === 1 && !prev[0].description && !prev[0].unit_price ? [invNewItem(desc, price?.price || '', 1, task.id)] : [...prev, invNewItem(desc, price?.price || '', 1, task.id)]);
|
||||
};
|
||||
const invAddRevision = (rev) => {
|
||||
const ini = (rev.task?.submissions || []).find(s => s.type === 'initial') || rev.task?.submissions?.[0];
|
||||
const svcType = ini?.service_type || rev.service_type || rev.task?.title || 'Revision';
|
||||
const price = invPriceList.find(p => p.service_type === svcType && p.price_type === 'revision');
|
||||
const version = Number(rev.version_number || 0);
|
||||
const qty = version >= 2 ? 1 : 1;
|
||||
const unitPrice = version >= 2 ? (price?.price || '') : 0;
|
||||
const vLabel = 'R' + String(version).padStart(2, '0');
|
||||
const desc = `${rev.task?.project?.name || 'No Project'} • ${rev.task?.title || 'Revision'} • Revision ${vLabel}`;
|
||||
const revisionChargeQty = getRevisionChargeQuantity(rev?.version_number);
|
||||
const qty = revisionChargeQty > 0 ? revisionChargeQty : 1;
|
||||
const unitPrice = revisionChargeQty > 0 ? (price?.price || '') : 0;
|
||||
const desc = invBuildRevisionItemDescription(rev);
|
||||
setInvItems(prev => prev.length === 1 && !prev[0].description && !prev[0].unit_price ? [invNewItem(desc, unitPrice, qty, rev.task_id, rev.id)] : [...prev, invNewItem(desc, unitPrice, qty, rev.task_id, rev.id)]);
|
||||
};
|
||||
const invUpdateItem = (id, field, val) => setInvItems(prev => prev.map(it => it.id === id ? { ...it, [field]: val } : it));
|
||||
@@ -226,14 +416,18 @@ export default function Invoices() {
|
||||
const payUrl = `https://portal.fourgebranding.com/pay/${encodeURIComponent(invoiceNumber)}`;
|
||||
const pdfItems = validItems.map(it => ({ description: it.description, quantity: Number(it.quantity) || 1, unit_price: Number(it.unit_price) || 0 }));
|
||||
let attachments = [];
|
||||
try { const pdf = await withTimeout(generateInvoicePDF({ ...invoice, status: 'sent' }, invSelectedCompany, pdfItems, { save: false }), 8000, 'PDF'); attachments = [await withTimeout(blobToEmailAttachment(pdf, `${invoiceNumber}.pdf`), 5000, 'Attachment')]; } catch {}
|
||||
try { const pdf = await withTimeout(generateInvoicePDF({ ...invoice, status: 'sent' }, invSelectedCompany, pdfItems, { save: false }), 8000, 'PDF'); attachments = [await withTimeout(blobToEmailAttachment(pdf, `${invoiceNumber}.pdf`), 5000, 'Attachment')]; } catch { /* PDF attach failed — send email without attachment */ }
|
||||
await withTimeout(sendEmail('invoice_sent', invEmail.trim(), { invoiceNumber, billTo: invBillTo || invSelectedCompany?.name, total: `$${invTotal.toFixed(2)}`, dueDate, payUrl, notes: invNotes || '' }, attachments), 12000, 'Email');
|
||||
await supabase.from('invoices').update({ status: 'sent' }).eq('id', invoice.id);
|
||||
} catch (e) { alert(`Invoice saved as draft — email failed: ${e.message}`); }
|
||||
}
|
||||
setInvoices(prev => [{ ...invoice, company: { name: invSelectedCompany?.name || '' } }, ...prev]);
|
||||
const createdInvoice = {
|
||||
...invoice,
|
||||
company: invSelectedCompany ? { id: invSelectedCompany.id, name: invSelectedCompany.name } : null,
|
||||
};
|
||||
setInvoices(prev => [createdInvoice, ...prev]);
|
||||
invClose();
|
||||
navigate(`/invoices/${invoice.id}`);
|
||||
setViewingInvoice(createdInvoice);
|
||||
} catch (e) { alert(`Failed to save invoice: ${e.message || 'Unknown error'}`); }
|
||||
setInvSaving(false);
|
||||
};
|
||||
@@ -409,26 +603,7 @@ export default function Invoices() {
|
||||
};
|
||||
|
||||
const handleSendSubcontractorPO = async (po) => {
|
||||
const sentPO = await updateSubcontractorPO(po, { status: 'sent', sent_at: new Date().toISOString() }, 'Failed to send PO');
|
||||
if (!sentPO?.profile?.email) return;
|
||||
try {
|
||||
await sendEmail('subcontractor_po_sent', sentPO.profile.email, {
|
||||
poNumber: sentPO.po_number || 'Purchase Order',
|
||||
subcontractorName: sentPO.profile?.name || 'there',
|
||||
projectName: sentPO.project?.name || 'Subcontractor Work',
|
||||
companyName: sentPO.project?.company?.name || 'Fourge Branding',
|
||||
amount: `$${Number(sentPO.amount).toFixed(2)}`,
|
||||
dueDate: sentPO.due_date ? new Date(sentPO.due_date).toLocaleDateString() : 'Not set',
|
||||
terms: sentPO.terms || 'Net 15',
|
||||
scope: sentPO.items?.length
|
||||
? sentPO.items.map(item => `${item.description} — $${Number(item.amount).toFixed(2)}`).join('\n')
|
||||
: sentPO.description,
|
||||
portalUrl: 'https://portal.fourgebranding.com/my-purchase-orders',
|
||||
});
|
||||
} catch (emailError) {
|
||||
console.error('Failed to email subcontractor PO:', emailError);
|
||||
alert(`PO was marked sent, but the email failed: ${emailError.message || 'unknown error'}`);
|
||||
}
|
||||
await updateSubcontractorPO(po, { status: 'sent', sent_at: new Date().toISOString() }, 'Failed to send PO');
|
||||
};
|
||||
|
||||
const handleReadyToPaySubcontractorPO = (po) => {
|
||||
@@ -490,6 +665,56 @@ export default function Invoices() {
|
||||
setMarkingPaid('');
|
||||
};
|
||||
|
||||
const handleDownloadSubInvoiceReceipt = async (invoice) => {
|
||||
const items = invoice.items || [];
|
||||
const total = items.reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
|
||||
try {
|
||||
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: (invoice.paid_at || new Date().toISOString()).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,
|
||||
})),
|
||||
});
|
||||
} catch (err) {
|
||||
alert(`Failed to download receipt: ${err.message || 'unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadSubInvoice = async (invoice) => {
|
||||
const items = invoice.items || [];
|
||||
const total = items.reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
|
||||
try {
|
||||
await generateSubcontractorPOPDF({
|
||||
po_number: invoice.invoice_number,
|
||||
status: invoice.status || 'draft',
|
||||
profile: invoice.profile,
|
||||
project: { name: 'Subcontractor Invoice', company: { name: 'Fourge Branding' } },
|
||||
date: invoice.created_at?.split('T')[0],
|
||||
due_date: (invoice.paid_at || invoice.submitted_at || invoice.created_at || new Date().toISOString()).split('T')[0],
|
||||
amount: total,
|
||||
description: invoice.status === 'paid' ? 'Payment for completed subcontractor work.' : 'Subcontractor invoice for completed work.',
|
||||
notes: invoice.notes,
|
||||
items: 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) {
|
||||
alert(`Failed to download invoice: ${err.message || 'unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReopenSubcontractorPO = async (po) => {
|
||||
updateSubcontractorPO(po, { status: 'draft', paid_at: null, cancelled_at: null }, 'Failed to reopen PO');
|
||||
};
|
||||
@@ -622,7 +847,7 @@ export default function Invoices() {
|
||||
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Layout loading={pageLoading}>
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
{/* Top row: 60% chart + 40% stat cards */}
|
||||
{(() => {
|
||||
@@ -653,7 +878,7 @@ export default function Invoices() {
|
||||
<div style={{ ...CARD }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Revenue Overview</span>
|
||||
<select className="filter-select" value={chartYear} onChange={e => setChartYear(Number(e.target.value))} style={{ width: 90, borderRadius: 8, fontSize: 11, fontWeight: 500 }}>
|
||||
<select className="filter-select" value={chartYear} onChange={e => setExportYear(Number(e.target.value))} style={{ width: 90, borderRadius: 8, fontSize: 11, fontWeight: 500 }}>
|
||||
{(paidYears.length > 0 ? paidYears : [new Date().getFullYear()]).map(y => <option key={y} value={y}>{y}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
@@ -694,7 +919,6 @@ export default function Invoices() {
|
||||
{ id: 'expenses', label: 'Expenses' },
|
||||
{ id: 'subcontractors', label: 'Subcontractors' },
|
||||
{ id: 'invoices', label: 'Invoices' },
|
||||
{ id: 'legacy', label: 'Legacy' },
|
||||
].map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
@@ -1075,7 +1299,7 @@ export default function Invoices() {
|
||||
return (
|
||||
<tr key={inv.id}>
|
||||
<td style={{ ...TD }}>
|
||||
<button type="button" className="table-link" onClick={() => navigate(`/sub-invoices/${inv.id}`)}>
|
||||
<button type="button" className="table-link" onClick={() => setViewingSubInvoice(inv)}>
|
||||
{inv.invoice_number || '—'}
|
||||
</button>
|
||||
</td>
|
||||
@@ -1195,7 +1419,7 @@ export default function Invoices() {
|
||||
<tbody>{sorted.map(inv => (
|
||||
<tr key={inv.id}>
|
||||
<td style={{ ...TD }}>
|
||||
<button type="button" className="dashboard-inline-link" onClick={() => navigate(`/invoices/${inv.id}`)}>{inv.invoice_number}</button>
|
||||
<button type="button" className="dashboard-inline-link" onClick={() => setViewingInvoice(inv)}>{inv.invoice_number}</button>
|
||||
</td>
|
||||
<td style={{ ...TD }}>
|
||||
<button type="button" className="dashboard-inline-link" onClick={() => inv.company?.id && navigate(`/company/${inv.company.id}`)}>{inv.company?.name || inv.bill_to || '—'}</button>
|
||||
@@ -1252,24 +1476,22 @@ export default function Invoices() {
|
||||
})()}
|
||||
|
||||
{financeTab === 'legacy' && <div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 0, marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10, minHeight: 'var(--btn-height)' }}>
|
||||
{[
|
||||
{ id: 'invoices', label: 'INVOICES' },
|
||||
{ id: 'expenses', label: 'EXPENSES' },
|
||||
{ id: 'sub-invoices', label: 'SUBCONTRACTOR INVOICES' },
|
||||
].map((t, i, arr) => (
|
||||
<span key={t.id} style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab(t.id)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontSize: 13, letterSpacing: 0.5, fontWeight: activeTab === t.id ? 600 : 400, color: activeTab === t.id ? 'var(--text-primary)' : 'var(--text-muted)', fontFamily: 'inherit' }}
|
||||
>{t.label}</button>
|
||||
{i < arr.length - 1 && <span style={{ margin: '0 10px', color: 'var(--border)', userSelect: 'none' }}>|</span>}
|
||||
</span>
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(t.id)}
|
||||
className={`section-tab-btn${activeTab === t.id ? ' is-active' : ''}`}
|
||||
>{t.label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 10, minHeight: 'var(--btn-height)', flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{companyNames.length > 0 && activeTab === 'invoices' && (
|
||||
<select className="filter-select" style={{ width: 120 }} value={filterCompany} onChange={e => setFilterCompany(e.target.value)}>
|
||||
@@ -1310,10 +1532,10 @@ export default function Invoices() {
|
||||
{loading ? (
|
||||
<p style={{ color: 'var(--text-muted)' }}>Loading...</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>No invoices.</p>
|
||||
<div className="card card-empty-center">No invoices</div>
|
||||
) : (
|
||||
<div style={{ background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
|
||||
<table>
|
||||
<div className="table-wrapper">
|
||||
<table className="table-sticky-head">
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="invoice_number" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle}>Invoice #</SortTh>
|
||||
@@ -1330,7 +1552,7 @@ export default function Invoices() {
|
||||
if (key === 'invoice_date' || key === 'due_date') return new Date(inv[key]).getTime();
|
||||
return inv[key] || inv.company?.name || '';
|
||||
}).map(inv => (
|
||||
<tr key={inv.id} onClick={() => navigate(`/invoices/${inv.id}`)} style={{ cursor: 'pointer' }}>
|
||||
<tr key={inv.id} onClick={() => setViewingInvoice(inv)} style={{ cursor: 'pointer' }}>
|
||||
<td style={{ fontWeight: 400 }}>{inv.invoice_number}</td>
|
||||
<td style={{ fontWeight: 400 }}>{inv.bill_to || inv.company?.name}</td>
|
||||
<td style={{ color: 'var(--text-muted)' }}>{new Date(inv.invoice_date).toLocaleDateString()}</td>
|
||||
@@ -1359,17 +1581,17 @@ export default function Invoices() {
|
||||
{activeTab === 'expenses' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
<div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
|
||||
{expensesLoading ? (
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Loading...</p>
|
||||
) : expensesError ? (
|
||||
<p style={{ color: 'var(--danger)', fontSize: 13 }}>{expensesError}</p>
|
||||
) : filteredExpenses.length === 0 ? (
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>No expenses yet.</p>
|
||||
<div className="card card-empty-center">No expenses</div>
|
||||
) : (
|
||||
<div style={{ background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
|
||||
<table>
|
||||
<div className="table-wrapper">
|
||||
<table className="table-sticky-head">
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="date" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle}>Date</SortTh>
|
||||
@@ -1431,16 +1653,16 @@ export default function Invoices() {
|
||||
|
||||
{activeTab === 'sub-invoices' && (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
{subInvoicesLoading ? (
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Loading...</p>
|
||||
) : subInvoicesError ? (
|
||||
<p style={{ color: 'var(--danger)', fontSize: 13 }}>{subInvoicesError}</p>
|
||||
) : subInvoices.length === 0 ? (
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>No sub invoices yet.</p>
|
||||
<div className="card card-empty-center">No sub invoices</div>
|
||||
) : (
|
||||
<div style={{ background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
|
||||
<table>
|
||||
<div className="table-wrapper">
|
||||
<table className="table-sticky-head">
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="invoice_number" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle}>Invoice #</SortTh>
|
||||
@@ -1461,7 +1683,7 @@ export default function Invoices() {
|
||||
const total = (inv.items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
|
||||
const subInvoiceStatusColor = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
|
||||
return (
|
||||
<tr key={inv.id} style={{ cursor: 'pointer' }} onClick={() => navigate(`/sub-invoices/${inv.id}`)}>
|
||||
<tr key={inv.id} style={{ cursor: 'pointer' }} onClick={() => setViewingSubInvoice(inv)}>
|
||||
<td style={{ fontWeight: 400 }}>{inv.invoice_number}</td>
|
||||
<td>
|
||||
<div style={{ fontWeight: 400 }}>{inv.profile?.name || 'External'}</div>
|
||||
@@ -1488,9 +1710,139 @@ export default function Invoices() {
|
||||
)}
|
||||
</div>}{/* end legacy wrapper */}
|
||||
|
||||
{viewingSubInvoice && (() => {
|
||||
const invoice = viewingSubInvoice;
|
||||
const sortedItems = [...(invoice.items || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
|
||||
const total = sortedItems.reduce((s, item) => s + Number(item.unit_price || 0) * Number(item.quantity || 1), 0);
|
||||
const invoiceStatus = invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—';
|
||||
const subInvoiceStatusColor = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
|
||||
return (
|
||||
<div style={popupOverlayStyle} onClick={() => { if (!markingPaid) setViewingSubInvoice(null); }}>
|
||||
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16, paddingBottom: 18, borderBottom: '1px solid var(--border)' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 18, fontWeight: 500, color: 'var(--text-primary)', letterSpacing: 0.2, lineHeight: 1.1 }}>{invoice.invoice_number || 'Subcontractor Invoice'}</div>
|
||||
<div style={{ marginTop: 6, fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||
{invoice.profile?.name || 'External'}{invoice.profile?.email ? ` · ${invoice.profile.email}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={subInvoiceStatusColor[invoice.status] || 'not_started'} label={invoiceStatus} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 18, padding: '18px 0', borderBottom: '1px solid var(--border)' }}>
|
||||
<div>
|
||||
<div style={FIELD_LABEL_STYLE}>Submitted</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{invoice.submitted_at ? new Date(invoice.submitted_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={FIELD_LABEL_STYLE}>Paid</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{invoice.paid_at ? new Date(invoice.paid_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={FIELD_LABEL_STYLE}>Line Items</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{sortedItems.length}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={FIELD_LABEL_STYLE}>Total</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 500, color: 'var(--accent)', lineHeight: 1.1 }}>${total.toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto', paddingTop: 18 }}>
|
||||
{sortedItems.length === 0 ? (
|
||||
<div className="card-empty-center">No line items</div>
|
||||
) : (
|
||||
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||
<colgroup>
|
||||
<col style={{ width: '8%' }} />
|
||||
<col style={{ width: '10%' }} />
|
||||
<col style={{ width: '32%' }} />
|
||||
<col style={{ width: '16%' }} />
|
||||
<col style={{ width: '10%' }} />
|
||||
<col style={{ width: '12%' }} />
|
||||
<col style={{ width: '12%' }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'center' }}>Work</th>
|
||||
<th style={{ textAlign: 'center' }}>R#</th>
|
||||
<th style={{ textAlign: 'left' }}>Description</th>
|
||||
<th style={{ textAlign: 'center' }}>Type</th>
|
||||
<th style={{ textAlign: 'center' }}>Qty</th>
|
||||
<th style={{ textAlign: 'center' }}>Unit Price</th>
|
||||
<th style={{ textAlign: 'center' }}>Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedItems.map(item => (
|
||||
<tr key={item.id}>
|
||||
<td style={{ padding: '5px 0', fontSize: 13, color: 'var(--text-primary)', textAlign: 'center' }}>{subInvoiceItemWorkLabel(item)}</td>
|
||||
<td style={{ padding: '5px 0', fontSize: 13, color: 'var(--text-primary)', textAlign: 'center' }}>{subInvoiceItemVersionLabel(item)}</td>
|
||||
<td style={{ padding: '5px 0', fontSize: 13, color: 'var(--text-primary)' }}>{cleanSubInvoiceItemDescription(item)}</td>
|
||||
<td style={{ padding: '5px 0', fontSize: 13, color: 'var(--text-primary)', textAlign: 'center' }}>{viewingSubInvoiceTaskTypeMap[item.task_id] || 'Other'}</td>
|
||||
<td style={{ padding: '5px 0', textAlign: 'center', fontSize: 13, color: 'var(--text-primary)' }}>{item.quantity || 1}</td>
|
||||
<td style={{ padding: '5px 0', textAlign: 'center', fontSize: 13, color: 'var(--text-primary)' }}>${Number(item.unit_price || 0).toFixed(2)}</td>
|
||||
<td style={{ padding: '5px 0', textAlign: 'center', fontSize: 13, color: 'var(--text-primary)' }}>${(Number(item.unit_price || 0) * Number(item.quantity || 1)).toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{invoice.notes && (
|
||||
<div style={{ marginTop: 18, padding: '12px 14px', background: 'var(--card-bg-2)', borderRadius: 6, border: '1px solid var(--border)' }}>
|
||||
<div style={FIELD_LABEL_STYLE}>Notes</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)', whiteSpace: 'pre-wrap' }}>{invoice.notes}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="modal-action-row" style={{ paddingTop: 18, borderTop: '1px solid var(--border)' }}>
|
||||
<button type="button" className="btn btn-outline" onClick={() => handleDownloadSubInvoice(invoice)}>
|
||||
Download Invoice
|
||||
</button>
|
||||
{invoice.status === 'submitted' && (
|
||||
<LoadingButton className="btn btn-outline" onClick={async () => {
|
||||
await handleMarkSubInvoicePaid(invoice);
|
||||
setViewingSubInvoice(current => current?.id === invoice.id ? { ...current, status: 'paid', paid_at: new Date().toISOString() } : current);
|
||||
}} loading={markingPaid === invoice.id} loadingText="Processing…">
|
||||
Mark as Paid
|
||||
</LoadingButton>
|
||||
)}
|
||||
{invoice.status === 'paid' && (
|
||||
<button type="button" className="btn btn-outline" onClick={() => handleDownloadSubInvoiceReceipt(invoice)}>
|
||||
Download Receipt
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn btn-outline" onClick={() => setViewingSubInvoice(null)}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{viewingInvoice && (
|
||||
<TeamInvoiceDetailPanel
|
||||
invoiceId={viewingInvoice.id}
|
||||
initialInvoice={viewingInvoice}
|
||||
embedded
|
||||
onClose={() => setViewingInvoice(null)}
|
||||
onInvoiceUpdated={(updatedInvoice) => {
|
||||
setViewingInvoice(updatedInvoice);
|
||||
setInvoices((current) => current.map((invoice) => (invoice.id === updatedInvoice.id ? { ...invoice, ...updatedInvoice } : invoice)));
|
||||
}}
|
||||
onInvoiceDeleted={(deletedId) => {
|
||||
setInvoices((current) => current.filter((invoice) => invoice.id !== deletedId));
|
||||
setViewingInvoice((current) => (current?.id === deletedId ? null : current));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewingExpense && (() => {
|
||||
const LABEL = { fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 3 };
|
||||
const INPUT = { fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' };
|
||||
const LABEL = { ...FIELD_LABEL_STYLE, marginBottom: 3 };
|
||||
const INPUT = FINANCE_MODAL_INPUT_STYLE;
|
||||
const isPdf = viewingExpense.receipt_name?.toLowerCase().endsWith('.pdf');
|
||||
const isDirty = expenseDetailEditing && (
|
||||
newExpense.amount !== String(viewingExpense.amount ?? '') ||
|
||||
@@ -1510,9 +1862,13 @@ export default function Invoices() {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, flex: '0 0 260px', overflowY: 'auto' }}>
|
||||
<div style={LABEL}>Expense Detail</div>
|
||||
{expenseDetailEditing ? (
|
||||
<input type="number" step="0.01" min="0" required style={{ ...INPUT, fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#ef4444', fontVariantNumeric: 'tabular-nums', padding: '0 6px' }} value={newExpense.amount} onChange={e => setNewExpense(p => ({ ...p, amount: e.target.value }))} />
|
||||
<CurrencyInput
|
||||
value={newExpense.amount}
|
||||
required
|
||||
onChange={e => setNewExpense(p => ({ ...p, amount: e.target.value }))}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#ef4444', fontVariantNumeric: 'tabular-nums' }}>
|
||||
<div style={{ ...FINANCE_MODAL_TOTAL_VALUE_STYLE, color: 'var(--danger)' }}>
|
||||
${Number(viewingExpense.amount).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</div>
|
||||
)}
|
||||
@@ -1568,7 +1924,7 @@ export default function Invoices() {
|
||||
)}
|
||||
</div>
|
||||
{/* Footer: buttons bottom-right */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 14, marginTop: 14, flexShrink: 0 }}>
|
||||
<div className="modal-action-row" style={{ paddingTop: 14, marginTop: 14, flexShrink: 0 }}>
|
||||
{expenseDetailEditing ? (
|
||||
<>
|
||||
<button className="btn btn-outline" disabled={!isDirty || addingExpense} onClick={async () => { await saveExpense(); setExpenseDetailEditing(false); setViewingExpense(null); }}>{addingExpense ? 'Saving…' : 'Save'}</button>
|
||||
@@ -1590,8 +1946,8 @@ export default function Invoices() {
|
||||
})()}
|
||||
|
||||
{showExpenseForm && (() => {
|
||||
const LABEL = { fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 3 };
|
||||
const INPUT = { fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' };
|
||||
const LABEL = { ...FIELD_LABEL_STYLE, marginBottom: 3 };
|
||||
const INPUT = FINANCE_MODAL_INPUT_STYLE;
|
||||
return (
|
||||
<div style={popupOverlayStyle} onClick={cancelExpenseEdit}>
|
||||
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
|
||||
@@ -1601,7 +1957,11 @@ export default function Invoices() {
|
||||
{/* Left: fields */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, flex: '0 0 260px', overflowY: 'auto' }}>
|
||||
<div style={LABEL}>New Expense</div>
|
||||
<input type="number" step="0.01" min="0" required placeholder="0.00" style={{ ...INPUT, fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#ef4444', fontVariantNumeric: 'tabular-nums', padding: '0 6px' }} value={newExpense.amount} onChange={e => setNewExpense(p => ({ ...p, amount: e.target.value }))} />
|
||||
<CurrencyInput
|
||||
value={newExpense.amount}
|
||||
required
|
||||
onChange={e => setNewExpense(p => ({ ...p, amount: e.target.value }))}
|
||||
/>
|
||||
<div>
|
||||
<div style={LABEL}>Description</div>
|
||||
<input type="text" required placeholder="What was this for?" style={INPUT} value={newExpense.description} onChange={e => setNewExpense(p => ({ ...p, description: e.target.value }))} />
|
||||
@@ -1629,7 +1989,7 @@ export default function Invoices() {
|
||||
</div>
|
||||
</div>
|
||||
{/* Footer */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 14, marginTop: 14, flexShrink: 0 }}>
|
||||
<div className="modal-action-row" style={{ paddingTop: 14, marginTop: 14, flexShrink: 0 }}>
|
||||
<button type="submit" className="btn btn-outline" disabled={addingExpense}>{addingExpense ? 'Saving…' : 'Save'}</button>
|
||||
<button type="button" className="btn btn-outline" onClick={cancelExpenseEdit}>Cancel</button>
|
||||
</div>
|
||||
@@ -1642,8 +2002,8 @@ export default function Invoices() {
|
||||
</div>{/* end flex column wrapper */}
|
||||
|
||||
{showInvoiceForm && (() => {
|
||||
const LABEL = { fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 3 };
|
||||
const INPUT = { fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' };
|
||||
const LABEL = { ...FIELD_LABEL_STYLE, marginBottom: 3 };
|
||||
const INPUT = FINANCE_MODAL_INPUT_STYLE;
|
||||
return (
|
||||
<div style={popupOverlayStyle} onClick={() => { if (!invSaving) invClose(); }}>
|
||||
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
|
||||
@@ -1672,11 +2032,11 @@ export default function Invoices() {
|
||||
</>}
|
||||
<div>
|
||||
<div style={LABEL}>Notes</div>
|
||||
<textarea style={{ ...INPUT, minHeight: 72, resize: 'vertical' }} value={invNotes} onChange={e => setInvNotes(e.target.value)} placeholder="Payment terms, notes…" />
|
||||
<textarea style={FINANCE_MODAL_TEXTAREA_STYLE} value={invNotes} onChange={e => setInvNotes(e.target.value)} placeholder="Payment terms, notes…" />
|
||||
</div>
|
||||
<div style={{ marginTop: 'auto', paddingTop: 8 }}>
|
||||
<div style={LABEL}>Total</div>
|
||||
<div style={{ fontSize: 28, fontWeight: 400, color: 'var(--accent)', fontVariantNumeric: 'tabular-nums' }}>${invTotal.toFixed(2)}</div>
|
||||
<div style={FINANCE_MODAL_TOTAL_VALUE_STYLE}>${invTotal.toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1697,7 +2057,10 @@ export default function Invoices() {
|
||||
return (
|
||||
<div key={t.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 10px', background: 'var(--card-bg-2)', borderRadius: 4, border: '1px solid var(--border)', marginBottom: 4 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 12 }}>{t.project?.name} • {t.title || t.service_type}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span className="badge badge-initial">New</span>
|
||||
<div style={{ fontSize: 12 }}>{t.project?.name} • {t.title || t.service_type}</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{price ? `$${Number(price.price).toFixed(2)}` : 'No price'}</div>
|
||||
</div>
|
||||
<button type="button" className="btn btn-outline" style={{ fontSize: 11 }} disabled={added} onClick={() => invAddTask(t)}>{added ? 'Added' : '+ Add'}</button>
|
||||
@@ -1715,9 +2078,20 @@ export default function Invoices() {
|
||||
</div>
|
||||
{invUnbilledRevisions.map(r => {
|
||||
const added = invItems.some(i => i.submission_id === r.id);
|
||||
const revisionQty = getRevisionChargeQuantity(r?.version_number);
|
||||
const initial = (r.task?.submissions || []).find(s => s.type === 'initial') || r.task?.submissions?.[0];
|
||||
const revisionServiceType = initial?.service_type || r.service_type || r.task?.title || 'Revision';
|
||||
const revisionPrice = invPriceList.find(p => p.service_type === revisionServiceType && p.price_type === 'revision');
|
||||
const revisionLabel = revisionQty > 0 ? (revisionPrice ? `$${Number(revisionPrice.price).toFixed(2)}` : 'No price') : 'Free';
|
||||
return (
|
||||
<div key={r.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 10px', background: 'var(--card-bg-2)', borderRadius: 4, border: '1px solid var(--border)', marginBottom: 4 }}>
|
||||
<div style={{ fontSize: 12 }}>{r.task?.project?.name} • {r.task?.title} • R{String(r.version_number || 0).padStart(2, '0')}</div>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span className="badge badge-client_revision">Revision</span>
|
||||
<div style={{ fontSize: 12 }}>{r.task?.project?.name} • {r.task?.title} • R{String(r.version_number || 0).padStart(2, '0')}</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{revisionLabel}</div>
|
||||
</div>
|
||||
<button type="button" className="btn btn-outline" style={{ fontSize: 11 }} disabled={added} onClick={() => invAddRevision(r)}>{added ? 'Added' : '+ Add'}</button>
|
||||
</div>
|
||||
);
|
||||
@@ -1727,16 +2101,19 @@ export default function Invoices() {
|
||||
{/* Line items */}
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={LABEL}>Line Items</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '20px 1fr 60px 100px 90px 28px', gap: 6, marginBottom: 6 }}>
|
||||
{['', 'Description', 'Qty', 'Unit Price', 'Total', ''].map((h, i) => <div key={i} style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.5, color: 'var(--text-muted)', textAlign: i > 2 ? 'right' : 'left' }}>{h}</div>)}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 60px 100px 90px 28px', gap: 6, marginBottom: 6 }}>
|
||||
{['', 'Type', 'Description', 'Qty', 'Unit Price', 'Total', ''].map((h, i) => <div key={i} style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.5, color: 'var(--text-muted)', textAlign: i > 3 ? 'right' : 'left' }}>{h}</div>)}
|
||||
</div>
|
||||
{invItems.map((item, idx) => (
|
||||
<div key={item.id} onDragOver={e => e.preventDefault()} onDrop={() => invHandleDrop(idx)} style={{ display: 'grid', gridTemplateColumns: '20px 1fr 60px 100px 90px 28px', gap: 6, alignItems: 'center', marginBottom: 4 }}>
|
||||
<div key={item.id} onDragOver={e => e.preventDefault()} onDrop={() => invHandleDrop(idx)} style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 60px 100px 90px 28px', gap: 6, alignItems: 'center', marginBottom: 4 }}>
|
||||
<div draggable onDragStart={() => { invDragItem.current = idx; }} style={{ cursor: 'grab', color: 'var(--text-muted)', fontSize: 12, textAlign: 'center', userSelect: 'none' }}>⠿</div>
|
||||
<input style={{ ...INPUT, padding: '4px 8px' }} type="text" placeholder="Description…" value={item.description} onChange={e => invUpdateItem(item.id, 'description', e.target.value)} />
|
||||
<input style={{ ...INPUT, padding: '4px 6px', textAlign: 'center' }} type="number" min="1" value={item.quantity} onChange={e => invUpdateItem(item.id, 'quantity', e.target.value)} />
|
||||
<input style={{ ...INPUT, padding: '4px 8px', textAlign: 'right' }} type="number" min="0" step="0.01" placeholder="0.00" value={item.unit_price} onChange={e => invUpdateItem(item.id, 'unit_price', e.target.value)} />
|
||||
<div style={{ textAlign: 'right', fontSize: 13, color: 'var(--text-primary)', paddingRight: 2 }}>${((Number(item.quantity) || 0) * (Number(item.unit_price) || 0)).toFixed(2)}</div>
|
||||
<span className={`badge ${item.submission_id ? 'badge-client_revision' : 'badge-initial'}`}>
|
||||
{item.submission_id ? 'Revision' : 'New'}
|
||||
</span>
|
||||
<input style={FINANCE_MODAL_LINE_ITEM_INPUT_STYLE} type="text" placeholder="Description…" value={item.description} onChange={e => invUpdateItem(item.id, 'description', e.target.value)} />
|
||||
<input style={{ ...FINANCE_MODAL_NUMBER_INPUT_STYLE, textAlign: 'center' }} type="number" min="1" value={item.quantity} onChange={e => invUpdateItem(item.id, 'quantity', e.target.value)} />
|
||||
<input style={{ ...FINANCE_MODAL_NUMBER_INPUT_STYLE, textAlign: 'right' }} type="number" min="0" step="0.01" placeholder="0.00" value={item.unit_price} onChange={e => invUpdateItem(item.id, 'unit_price', e.target.value)} />
|
||||
<div style={{ minHeight: 32, display: 'flex', alignItems: 'center', justifyContent: 'flex-end', textAlign: 'right', fontSize: 13, color: 'var(--text-primary)', paddingRight: 2, fontVariantNumeric: 'tabular-nums' }}>${((Number(item.quantity) || 0) * (Number(item.unit_price) || 0)).toFixed(2)}</div>
|
||||
<button type="button" onClick={() => invRemoveItem(item.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--danger)', fontSize: 14, padding: 2 }}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
@@ -1746,7 +2123,7 @@ export default function Invoices() {
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 14, marginTop: 14, flexShrink: 0 }}>
|
||||
<div className="modal-action-row" style={{ paddingTop: 14, marginTop: 14, flexShrink: 0 }}>
|
||||
<button type="button" className="btn btn-outline" disabled={invSaving} onClick={() => invHandleSave('draft')}>{invSaving ? 'Saving…' : 'Save Draft'}</button>
|
||||
<LoadingButton className="btn btn-outline" style={{ color: 'var(--accent)', borderColor: 'var(--accent)' }} onClick={() => invHandleSave('sent')} loading={invSaving} loadingText="Sending…">Finalise & Send</LoadingButton>
|
||||
<button type="button" className="btn btn-outline" disabled={invSaving} onClick={invClose}>Cancel</button>
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import jsPDF from 'jspdf';
|
||||
import autoTable from 'jspdf-autotable';
|
||||
import Layout from '../../components/Layout';
|
||||
import SortTh from '../../components/SortTh';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import LoadingButton from '../../components/LoadingButton';
|
||||
import { useSortable } from '../../hooks/useSortable';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { isReviewShadowDescription } from '../../lib/taskVersions';
|
||||
|
||||
function fmtVersion(versionNumber) {
|
||||
return `R${String(Number(versionNumber || 0)).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function billingStatusFromInvoices(items = []) {
|
||||
if (!items.length) return 'not_started';
|
||||
if (items.some((item) => item.invoice?.status === 'paid')) return 'paid';
|
||||
return 'invoiced';
|
||||
}
|
||||
|
||||
function billingLabel(status) {
|
||||
if (status === 'paid') return 'Paid';
|
||||
if (status === 'invoiced') return 'Invoiced';
|
||||
return 'Not Invoiced';
|
||||
}
|
||||
|
||||
function versionTypeLabel(versionNumber) {
|
||||
return Number(versionNumber || 0) === 0 ? 'New' : 'Revision';
|
||||
}
|
||||
|
||||
function versionStateFor(task, versionNumber) {
|
||||
const currentVersion = Number(task?.current_version || 0);
|
||||
const version = Number(versionNumber || 0);
|
||||
if (version < currentVersion) return 'completed';
|
||||
return task?.status || 'not_started';
|
||||
}
|
||||
|
||||
function rowSort(a, b) {
|
||||
const companyCmp = (a.company_name || '').localeCompare(b.company_name || '');
|
||||
if (companyCmp !== 0) return companyCmp;
|
||||
const projectCmp = (a.project_name || '').localeCompare(b.project_name || '');
|
||||
if (projectCmp !== 0) return projectCmp;
|
||||
const taskCmp = (a.task_title || '').localeCompare(b.task_title || '');
|
||||
if (taskCmp !== 0) return taskCmp;
|
||||
return Number(a.version_number || 0) - Number(b.version_number || 0);
|
||||
}
|
||||
|
||||
export default function TeamReports() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [companies, setCompanies] = useState([]);
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [reportRows, setReportRows] = useState([]);
|
||||
const [companyFilter, setCompanyFilter] = useState('all');
|
||||
const [projectFilter, setProjectFilter] = useState('all');
|
||||
const { sortKey, sortDir, toggle, sort } = useSortable('company_name');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const [
|
||||
{ data: taskRows, error: tasksError },
|
||||
{ data: submissionRows, error: submissionsError },
|
||||
{ data: invoiceItemRows, error: invoiceItemsError },
|
||||
] = await Promise.all([
|
||||
supabase
|
||||
.from('tasks')
|
||||
.select('id, title, status, current_version, project:projects(id, name, company:companies(id, name))')
|
||||
.order('title'),
|
||||
supabase
|
||||
.from('submissions')
|
||||
.select('id, task_id, type, version_number, submitted_at, invoiced, description')
|
||||
.in('type', ['initial', 'revision'])
|
||||
.order('submitted_at', { ascending: true }),
|
||||
supabase
|
||||
.from('invoice_items')
|
||||
.select('id, task_id, submission_id, invoice:invoices(id, status), submission:submissions(id, task_id, version_number)')
|
||||
]);
|
||||
|
||||
if (tasksError) throw tasksError;
|
||||
if (submissionsError) throw submissionsError;
|
||||
if (invoiceItemsError) throw invoiceItemsError;
|
||||
if (cancelled) return;
|
||||
|
||||
const taskMap = new Map((taskRows || []).map((task) => [task.id, task]));
|
||||
const companiesMap = new Map();
|
||||
const projectsMap = new Map();
|
||||
(taskRows || []).forEach((task) => {
|
||||
const company = task.project?.company;
|
||||
if (company?.id && !companiesMap.has(company.id)) companiesMap.set(company.id, company);
|
||||
if (task.project?.id && !projectsMap.has(task.project.id)) projectsMap.set(task.project.id, task.project);
|
||||
});
|
||||
|
||||
const versionMap = new Map();
|
||||
for (const submission of (submissionRows || [])) {
|
||||
if (isReviewShadowDescription(submission.description)) continue;
|
||||
const versionNumber = Number(submission.version_number || 0);
|
||||
const key = `${submission.task_id}:${versionNumber}`;
|
||||
if (!versionMap.has(key)) {
|
||||
versionMap.set(key, {
|
||||
task_id: submission.task_id,
|
||||
version_number: versionNumber,
|
||||
submission_ids: [],
|
||||
first_submitted_at: submission.submitted_at || null,
|
||||
});
|
||||
}
|
||||
const entry = versionMap.get(key);
|
||||
entry.submission_ids.push(submission.id);
|
||||
if (!entry.first_submitted_at || (submission.submitted_at && submission.submitted_at < entry.first_submitted_at)) {
|
||||
entry.first_submitted_at = submission.submitted_at;
|
||||
}
|
||||
}
|
||||
|
||||
const invoiceItemGroups = new Map();
|
||||
for (const item of (invoiceItemRows || [])) {
|
||||
const versionNumber = item.submission?.version_number ?? (item.submission_id ? null : 0);
|
||||
const taskId = item.submission?.task_id || item.task_id;
|
||||
if (!taskId || versionNumber === null) continue;
|
||||
const key = `${taskId}:${Number(versionNumber || 0)}`;
|
||||
if (!invoiceItemGroups.has(key)) invoiceItemGroups.set(key, []);
|
||||
invoiceItemGroups.get(key).push(item);
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
for (const task of (taskRows || [])) {
|
||||
const company = task.project?.company;
|
||||
const project = task.project;
|
||||
const taskVersions = [];
|
||||
|
||||
const initialKey = `${task.id}:0`;
|
||||
if (versionMap.has(initialKey) || task) {
|
||||
taskVersions.push(versionMap.get(initialKey) || {
|
||||
task_id: task.id,
|
||||
version_number: 0,
|
||||
submission_ids: [],
|
||||
first_submitted_at: null,
|
||||
});
|
||||
}
|
||||
|
||||
for (const versionEntry of versionMap.values()) {
|
||||
if (versionEntry.task_id !== task.id) continue;
|
||||
if (Number(versionEntry.version_number || 0) === 0) continue;
|
||||
taskVersions.push(versionEntry);
|
||||
}
|
||||
|
||||
taskVersions.sort((a, b) => Number(a.version_number || 0) - Number(b.version_number || 0));
|
||||
|
||||
for (const versionEntry of taskVersions) {
|
||||
const key = `${task.id}:${Number(versionEntry.version_number || 0)}`;
|
||||
const invoiceItems = invoiceItemGroups.get(key) || [];
|
||||
const billingStatus = billingStatusFromInvoices(invoiceItems);
|
||||
rows.push({
|
||||
key,
|
||||
company_id: company?.id || '',
|
||||
company_name: company?.name || '—',
|
||||
project_id: project?.id || '',
|
||||
project_name: project?.name || '—',
|
||||
task_id: task.id,
|
||||
task_title: task.title || 'Untitled',
|
||||
version_number: Number(versionEntry.version_number || 0),
|
||||
version_label: fmtVersion(versionEntry.version_number),
|
||||
version_type: versionTypeLabel(versionEntry.version_number),
|
||||
version_state: versionStateFor(task, versionEntry.version_number),
|
||||
billing_status: billingStatus,
|
||||
billing_label: billingLabel(billingStatus),
|
||||
first_submitted_at: versionEntry.first_submitted_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
rows.sort(rowSort);
|
||||
|
||||
setCompanies([...companiesMap.values()].sort((a, b) => (a.name || '').localeCompare(b.name || '')));
|
||||
setProjects([...projectsMap.values()].sort((a, b) => (a.name || '').localeCompare(b.name || '')));
|
||||
setReportRows(rows);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err.message || 'Failed to load reports.');
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const filteredProjects = useMemo(() => {
|
||||
if (companyFilter === 'all') return projects;
|
||||
return projects.filter((project) => project.company?.id === companyFilter);
|
||||
}, [projects, companyFilter]);
|
||||
|
||||
const visibleRows = useMemo(() => {
|
||||
return reportRows.filter((row) => {
|
||||
if (companyFilter !== 'all' && row.company_id !== companyFilter) return false;
|
||||
if (projectFilter !== 'all' && row.project_id !== projectFilter) return false;
|
||||
return true;
|
||||
});
|
||||
}, [reportRows, companyFilter, projectFilter]);
|
||||
|
||||
const sortedRows = useMemo(() => sort(visibleRows, (row, key) => {
|
||||
if (key === 'company_name') return row.company_name || '';
|
||||
if (key === 'project_name') return row.project_name || '';
|
||||
if (key === 'task_title') return row.task_title || '';
|
||||
if (key === 'version_number') return Number(row.version_number || 0);
|
||||
if (key === 'version_type') return row.version_type || '';
|
||||
if (key === 'version_state') return row.version_state || '';
|
||||
if (key === 'billing_label') return row.billing_label || '';
|
||||
return '';
|
||||
}), [visibleRows, sort]);
|
||||
|
||||
const stats = useMemo(() => ({
|
||||
total: visibleRows.length,
|
||||
notInvoiced: visibleRows.filter((row) => row.billing_status === 'not_started').length,
|
||||
invoiced: visibleRows.filter((row) => row.billing_status === 'invoiced').length,
|
||||
paid: visibleRows.filter((row) => row.billing_status === 'paid').length,
|
||||
}), [visibleRows]);
|
||||
|
||||
const handleExportPdf = async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const doc = new jsPDF({ orientation: 'landscape', unit: 'pt', format: 'letter', compress: true });
|
||||
const title = 'Task Billing Report';
|
||||
const companyName = companyFilter === 'all' ? 'All Companies' : (companies.find((company) => company.id === companyFilter)?.name || 'Selected Company');
|
||||
const projectName = projectFilter === 'all' ? 'All Projects' : (projects.find((project) => project.id === projectFilter)?.name || 'Selected Project');
|
||||
const generatedOn = new Date().toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' });
|
||||
|
||||
doc.setFontSize(18);
|
||||
doc.text(title, 40, 40);
|
||||
doc.setFontSize(10);
|
||||
doc.text(`Company: ${companyName}`, 40, 60);
|
||||
doc.text(`Project: ${projectName}`, 240, 60);
|
||||
doc.text(`Generated: ${generatedOn}`, 440, 60);
|
||||
|
||||
autoTable(doc, {
|
||||
startY: 78,
|
||||
head: [['Company', 'Project', 'Task', 'Version', 'Type', 'Version Status', 'Billing']],
|
||||
body: sortedRows.map((row) => [
|
||||
row.company_name,
|
||||
row.project_name,
|
||||
row.task_title,
|
||||
row.version_label,
|
||||
row.version_type,
|
||||
row.version_state === 'client_review' ? 'In Review' : row.version_state === 'client_approved' ? 'Approved' : row.version_state.replace(/_/g, ' '),
|
||||
row.billing_label,
|
||||
]),
|
||||
styles: {
|
||||
fontSize: 9,
|
||||
cellPadding: 6,
|
||||
lineColor: [225, 225, 225],
|
||||
lineWidth: 0.25,
|
||||
},
|
||||
headStyles: {
|
||||
fillColor: [245, 165, 35],
|
||||
textColor: [17, 17, 17],
|
||||
fontStyle: 'bold',
|
||||
},
|
||||
alternateRowStyles: {
|
||||
fillColor: [250, 250, 250],
|
||||
},
|
||||
margin: { left: 40, right: 40, bottom: 30 },
|
||||
didDrawPage: ({ pageNumber }) => {
|
||||
doc.setFontSize(9);
|
||||
doc.text(`Page ${pageNumber}`, doc.internal.pageSize.width - 70, doc.internal.pageSize.height - 14);
|
||||
},
|
||||
});
|
||||
|
||||
doc.save(`task-billing-report-${new Date().toISOString().slice(0, 10)}.pdf`);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout loading={loading}>
|
||||
<div className="page-toolbar-grid" style={{ alignItems: 'center', marginBottom: 24 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 4 }}>Company</div>
|
||||
<select value={companyFilter} onChange={(event) => { setCompanyFilter(event.target.value); setProjectFilter('all'); }}>
|
||||
<option value="all">All Companies</option>
|
||||
{companies.map((company) => (
|
||||
<option key={company.id} value={company.id}>{company.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 4 }}>Project</div>
|
||||
<select value={projectFilter} onChange={(event) => setProjectFilter(event.target.value)}>
|
||||
<option value="all">All Projects</option>
|
||||
{filteredProjects.map((project) => (
|
||||
<option key={project.id} value={project.id}>{project.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'end' }}>
|
||||
<LoadingButton className="btn btn-primary" loading={exporting} loadingText="Generating..." onClick={handleExportPdf} disabled={loading || sortedRows.length === 0}>
|
||||
Export PDF
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dash-stat-grid" style={{ gridTemplateColumns: '1fr 1fr 1fr 1fr', marginBottom: 24 }}>
|
||||
<div className="stat-card">
|
||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 6 }}>Rows</div>
|
||||
<div style={{ fontSize: 28, lineHeight: 1.1 }}>{stats.total}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 6 }}>Not Invoiced</div>
|
||||
<div style={{ fontSize: 28, lineHeight: 1.1 }}>{stats.notInvoiced}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 6 }}>Invoiced</div>
|
||||
<div style={{ fontSize: 28, lineHeight: 1.1 }}>{stats.invoiced}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 6 }}>Paid</div>
|
||||
<div style={{ fontSize: 28, lineHeight: 1.1 }}>{stats.paid}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-wrapper" style={{ minHeight: 0 }}>
|
||||
{loading ? (
|
||||
<div style={{ padding: 24, fontSize: 13, color: 'var(--text-muted)', textAlign: 'center' }}>Loading report…</div>
|
||||
) : error ? (
|
||||
<div style={{ padding: 24, fontSize: 13, color: 'var(--danger)', textAlign: 'center' }}>{error}</div>
|
||||
) : sortedRows.length === 0 ? (
|
||||
<div style={{ padding: 24, fontSize: 13, color: 'var(--text-muted)', textAlign: 'center' }}>No report rows for the selected filters.</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||
<colgroup>
|
||||
<col style={{ width: '16%' }} />
|
||||
<col style={{ width: '16%' }} />
|
||||
<col style={{ width: '24%' }} />
|
||||
<col style={{ width: '8%' }} />
|
||||
<col style={{ width: '10%' }} />
|
||||
<col style={{ width: '13%' }} />
|
||||
<col style={{ width: '13%' }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="company_name" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Company</SortTh>
|
||||
<SortTh col="project_name" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Project</SortTh>
|
||||
<SortTh col="task_title" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Task Name</SortTh>
|
||||
<SortTh col="version_number" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>R#</SortTh>
|
||||
<SortTh col="version_type" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Type</SortTh>
|
||||
<SortTh col="version_state" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Version Status</SortTh>
|
||||
<SortTh col="billing_label" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Billing</SortTh>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedRows.map((row) => (
|
||||
<tr key={row.key}>
|
||||
<td>{row.company_name}</td>
|
||||
<td>{row.project_name}</td>
|
||||
<td>{row.task_title}</td>
|
||||
<td>{row.version_label}</td>
|
||||
<td><StatusBadge status={row.version_number === 0 ? 'initial' : 'revision'} label={row.version_type} /></td>
|
||||
<td><StatusBadge status={row.version_state} /></td>
|
||||
<td><StatusBadge status={row.billing_status} label={row.billing_label} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import SortTh from '../../components/SortTh';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import PageLoader from '../../components/PageLoader';
|
||||
import LoadingButton from '../../components/LoadingButton';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { generateSubcontractorPOPDF } from '../../lib/invoice';
|
||||
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
|
||||
import { useSortable } from '../../hooks/useSortable';
|
||||
|
||||
const statusColor = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
|
||||
import SubcontractorInvoiceDetailView from '../../components/SubcontractorInvoiceDetailView';
|
||||
|
||||
export default function SubInvoiceDetail() {
|
||||
const { id } = useParams();
|
||||
@@ -17,6 +16,7 @@ export default function SubInvoiceDetail() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const { sortKey, sortDir, toggle, sort } = useSortable('description');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -25,18 +25,24 @@ export default function SubInvoiceDetail() {
|
||||
.select('*, profile:profiles!subcontractor_invoices_profile_id_fkey(id, name, email), items:subcontractor_invoice_items(*)')
|
||||
.eq('id', id)
|
||||
.single()
|
||||
.then(({ data }) => {
|
||||
setInvoice(data);
|
||||
.then(({ data, error: err }) => {
|
||||
if (err || !data) setError('Invoice not found.');
|
||||
setInvoice(data || null);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [id]);
|
||||
|
||||
const total = (invoice?.items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
|
||||
const total = (invoice?.items || []).reduce((sum, item) => sum + Number(item.unit_price || 0) * Number(item.quantity || 1), 0);
|
||||
const sortedItems = [...(invoice?.items || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
|
||||
const tableItems = sort(sortedItems, (item, key) => {
|
||||
const typeSort = /\bR(\d{2})\b/i.test(String(item.description || ''))
|
||||
? Number(String(item.description).match(/\bR(\d{2})\b/i)?.[1] || 0)
|
||||
: item.task_id ? 0 : 999;
|
||||
if (key === 'type') return typeSort;
|
||||
if (key === 'description') return item.description || '';
|
||||
if (key === 'quantity') return Number(item.quantity || 0);
|
||||
if (key === 'unit_price') return Number(item.unit_price || 0);
|
||||
if (key === 'amount') return Number(item.unit_price || 0) * Number(item.quantity || 1);
|
||||
if (key === 'line_total') return Number(item.unit_price || 0) * Number(item.quantity || 1);
|
||||
return '';
|
||||
});
|
||||
|
||||
@@ -58,15 +64,18 @@ export default function SubInvoiceDetail() {
|
||||
});
|
||||
|
||||
const handleMarkPaid = async () => {
|
||||
if (!invoice) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const paidAt = new Date().toISOString();
|
||||
const { error } = await supabase.from('subcontractor_invoices').update({ status: 'paid', paid_at: paidAt }).eq('id', id);
|
||||
if (error) throw error;
|
||||
const { error: updateError } = await supabase.from('subcontractor_invoices').update({ status: 'paid', paid_at: paidAt }).eq('id', id);
|
||||
if (updateError) throw updateError;
|
||||
const updated = { ...invoice, status: 'paid', paid_at: paidAt };
|
||||
setInvoice(updated);
|
||||
let pdfBlob = null;
|
||||
try { pdfBlob = await generateSubcontractorPOPDF(buildPDFArgs(updated), { output: 'blob' }); } catch {}
|
||||
try {
|
||||
pdfBlob = await generateSubcontractorPOPDF(buildPDFArgs(updated), { output: 'blob' });
|
||||
} catch { /* PDF generation failed — continue without attachment */ }
|
||||
if (invoice.profile?.email) {
|
||||
try {
|
||||
const attachments = pdfBlob ? [await blobToEmailAttachment(pdfBlob, `${invoice.invoice_number}-receipt.pdf`)] : [];
|
||||
@@ -77,136 +86,90 @@ export default function SubInvoiceDetail() {
|
||||
paidDate: new Date(paidAt).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }),
|
||||
}, attachments);
|
||||
} catch (emailErr) {
|
||||
alert(`Marked paid, but email failed: ${emailErr.message || 'unknown error'}`);
|
||||
setError(`Marked paid, but email failed: ${emailErr.message || 'unknown error'}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Failed to mark as paid: ${err.message}`);
|
||||
setError(`Failed to mark as paid: ${err.message}`);
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const handleReceipt = async () => {
|
||||
if (!invoice) return;
|
||||
setGenerating(true);
|
||||
try {
|
||||
await generateSubcontractorPOPDF(buildPDFArgs(invoice));
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
setError(err.message);
|
||||
}
|
||||
setGenerating(false);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!invoice) return;
|
||||
if (!window.confirm(`Delete invoice ${invoice.invoice_number}? This cannot be undone.`)) return;
|
||||
setSaving(true);
|
||||
const { error } = await supabase.from('subcontractor_invoices').delete().eq('id', id);
|
||||
if (error) { alert('Failed to delete: ' + error.message); setSaving(false); return; }
|
||||
navigate('/invoices', { state: { tab: 'sub-invoices' } });
|
||||
const { error: deleteError } = await supabase.from('subcontractor_invoices').delete().eq('id', id);
|
||||
if (deleteError) {
|
||||
setError(`Failed to delete: ${deleteError.message}`);
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
navigate('/finances', { state: { tab: 'sub-invoices' } });
|
||||
};
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
if (!invoice) return <Layout><p style={{ padding: 24 }}>Invoice not found.</p></Layout>;
|
||||
if (loading) return <Layout><PageLoader /></Layout>;
|
||||
if (!invoice) return <Layout><p style={{ padding: 24 }}>{error || 'Invoice not found.'}</p></Layout>;
|
||||
|
||||
const sortedItems = [...(invoice.items || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
|
||||
const headerActions = (
|
||||
<>
|
||||
{invoice.status === 'submitted' ? (
|
||||
<LoadingButton className="btn btn-outline" loading={saving} loadingText="Processing…" disabled={saving} onClick={handleMarkPaid}>
|
||||
Mark as Paid
|
||||
</LoadingButton>
|
||||
) : null}
|
||||
{invoice.status === 'paid' ? (
|
||||
<LoadingButton className="btn btn-outline" loading={generating} loadingText="Generating…" disabled={generating} onClick={handleReceipt}>
|
||||
Download Receipt
|
||||
</LoadingButton>
|
||||
) : null}
|
||||
<button className="btn btn-outline" style={{ color: 'var(--danger)', borderColor: 'var(--danger)' }} onClick={handleDelete} disabled={saving}>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<button className="back-link" onClick={() => navigate('/invoices', { state: { tab: 'sub-invoices' } })}>
|
||||
← Back to Subcontractor Invoices
|
||||
</button>
|
||||
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">{invoice.invoice_number}</div>
|
||||
<div className="page-subtitle">
|
||||
{invoice.profile?.name || 'External'} · {invoice.profile?.email || '—'}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge
|
||||
status={statusColor[invoice.status] || 'not_started'}
|
||||
label={invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid-2" style={{ marginBottom: 24 }}>
|
||||
<div className="card">
|
||||
<div className="card-title">Invoice Info</div>
|
||||
<div className="detail-grid" style={{ marginBottom: 0 }}>
|
||||
<div className="detail-item"><label>Invoice #</label><p style={{ fontWeight: 400 }}>{invoice.invoice_number}</p></div>
|
||||
<div className="detail-item"><label>Status</label><p style={{ textTransform: 'capitalize' }}>{invoice.status}</p></div>
|
||||
<div className="detail-item"><label>Submitted</label><p>{invoice.submitted_at ? new Date(invoice.submitted_at).toLocaleDateString() : '—'}</p></div>
|
||||
{invoice.paid_at && (
|
||||
<div className="detail-item"><label>Paid On</label><p style={{ color: 'var(--success)', fontWeight: 400 }}>{new Date(invoice.paid_at).toLocaleDateString()}</p></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-title">Summary</div>
|
||||
<div className="detail-grid" style={{ marginBottom: 0 }}>
|
||||
<div className="detail-item"><label>Line Items</label><p>{sortedItems.length}</p></div>
|
||||
<div className="detail-item"><label>Total</label><p style={{ fontWeight: 400, fontSize: 18 }}>${total.toFixed(2)}</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginBottom: 24 }}>
|
||||
<div className="card-title">Line Items</div>
|
||||
{sortedItems.length === 0 ? (
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>No line items.</p>
|
||||
) : (
|
||||
<div className="table-wrapper" style={{ border: 'none' }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="description" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Description</SortTh>
|
||||
<SortTh col="quantity" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Qty</SortTh>
|
||||
<SortTh col="unit_price" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Unit Price</SortTh>
|
||||
<SortTh col="amount" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Amount</SortTh>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tableItems.map(item => (
|
||||
<tr key={item.id}>
|
||||
<td>{item.description}</td>
|
||||
<td style={{ textAlign: 'right' }}>{item.quantity}</td>
|
||||
<td style={{ textAlign: 'right' }}>${Number(item.unit_price).toFixed(2)}</td>
|
||||
<td style={{ textAlign: 'right', fontWeight: 400 }}>${(Number(item.unit_price) * Number(item.quantity || 1)).toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr>
|
||||
<td colSpan={3} style={{ textAlign: 'right', fontWeight: 400, borderTop: '1px solid var(--border)', paddingTop: 10 }}>Total</td>
|
||||
<td style={{ textAlign: 'right', fontWeight: 400, fontSize: 16, borderTop: '1px solid var(--border)', paddingTop: 10 }}>${total.toFixed(2)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<SubcontractorInvoiceDetailView
|
||||
error={error}
|
||||
headerActions={headerActions}
|
||||
leftCardTitle="Submitted By"
|
||||
leftCardBody={(
|
||||
<>
|
||||
<div style={{ fontSize: 15, fontWeight: 400 }}>{invoice.profile?.name || 'External'}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 4 }}>{invoice.profile?.email || '—'}</div>
|
||||
</>
|
||||
)}
|
||||
rightCardTitle="Invoice Details"
|
||||
rightCardBody={(
|
||||
<div className="invoice-detail-meta-grid">
|
||||
<div className="invoice-detail-meta-item"><label>Invoice #</label><p>{invoice.invoice_number}</p></div>
|
||||
<div className="invoice-detail-meta-item"><label>Status</label><p>{invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—'}</p></div>
|
||||
<div className="invoice-detail-meta-item"><label>Submitted</label><p>{invoice.submitted_at ? new Date(invoice.submitted_at).toLocaleDateString() : '—'}</p></div>
|
||||
{invoice.paid_at ? <div className="invoice-detail-meta-item"><label>Paid On</label><p style={{ color: 'var(--success, #16a34a)' }}>{new Date(invoice.paid_at).toLocaleDateString()}</p></div> : null}
|
||||
<div className="invoice-detail-meta-item"><label>Line Items</label><p>{sortedItems.length}</p></div>
|
||||
<div className="invoice-detail-meta-item"><label>Total</label><p style={{ fontSize: 18, color: 'var(--accent)' }}>${total.toFixed(2)}</p></div>
|
||||
</div>
|
||||
)}
|
||||
{invoice.notes && (
|
||||
<div style={{ marginTop: 16, padding: '12px 14px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 6 }}>Notes</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', whiteSpace: 'pre-wrap', margin: 0 }}>{invoice.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-title">Actions</div>
|
||||
<div className="action-buttons">
|
||||
{invoice.status === 'submitted' && (
|
||||
<button className="btn btn-success" onClick={handleMarkPaid} disabled={saving}>
|
||||
{saving ? 'Processing…' : 'Mark as Paid'}
|
||||
</button>
|
||||
)}
|
||||
{invoice.status === 'paid' && (
|
||||
<button className="btn btn-outline" onClick={handleReceipt} disabled={generating}>
|
||||
{generating ? 'Generating…' : 'Download Receipt'}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn-icon btn-icon-danger" title="Delete Invoice" onClick={handleDelete} disabled={saving}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
sortKey={sortKey}
|
||||
sortDir={sortDir}
|
||||
onSort={toggle}
|
||||
items={tableItems}
|
||||
notes={invoice.notes}
|
||||
total={total}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import PageLoader from '../../components/PageLoader';
|
||||
import LoadingButton from '../../components/LoadingButton';
|
||||
import SortTh from '../../components/SortTh';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { sendEmail } from '../../lib/email';
|
||||
import { generateSubcontractorPOPDF } from '../../lib/invoice';
|
||||
import { useSortable } from '../../hooks/useSortable';
|
||||
|
||||
@@ -73,49 +73,14 @@ export default function SubcontractorPODetail() {
|
||||
return data;
|
||||
};
|
||||
|
||||
const sendPOEmail = async (sentPO) => {
|
||||
if (!sentPO?.profile?.email) {
|
||||
alert('This subcontractor has no email on file.');
|
||||
return;
|
||||
}
|
||||
await sendEmail('subcontractor_po_sent', sentPO.profile.email, {
|
||||
poNumber: sentPO.po_number || 'Purchase Order',
|
||||
subcontractorName: sentPO.profile?.name || 'there',
|
||||
projectName: sentPO.project?.name || 'Subcontractor Work',
|
||||
companyName: sentPO.project?.company?.name || 'Fourge Branding',
|
||||
amount: `$${Number(sentPO.amount).toFixed(2)}`,
|
||||
dueDate: sentPO.due_date ? new Date(sentPO.due_date).toLocaleDateString() : 'Not set',
|
||||
terms: sentPO.terms || 'Net 15',
|
||||
scope: sentPO.items?.length
|
||||
? sentPO.items.map(item => `${item.description} - $${Number(item.amount).toFixed(2)}`).join('\n')
|
||||
: sentPO.description,
|
||||
portalUrl: 'https://portal.fourgebranding.com/my-purchase-orders',
|
||||
});
|
||||
};
|
||||
|
||||
const handleFinalizeSend = async () => {
|
||||
if (!window.confirm(`Finalize and send ${po.po_number || 'this PO'} to ${po.profile?.email || 'the subcontractor'}?`)) return;
|
||||
const sentPO = await updatePO({ status: 'sent', sent_at: new Date().toISOString() }, 'Failed to finalize PO');
|
||||
if (!sentPO) return;
|
||||
try {
|
||||
await sendPOEmail(sentPO);
|
||||
alert('PO email sent successfully.');
|
||||
} catch (error) {
|
||||
alert(`PO was finalized, but the email failed: ${error.message || 'unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResend = async () => {
|
||||
if (!window.confirm(`Resend ${po.po_number || 'this PO'} to ${po.profile?.email || 'the subcontractor'}?`)) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await sendPOEmail(po);
|
||||
alert('PO email sent successfully.');
|
||||
} catch (error) {
|
||||
alert(`Failed to send PO: ${error.message || 'unknown error'}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
alert('PO emails are disabled.');
|
||||
};
|
||||
|
||||
const handleReadyToPay = () => updatePO({ status: 'ready_to_pay' }, 'Failed to mark ready to pay');
|
||||
@@ -136,7 +101,7 @@ export default function SubcontractorPODetail() {
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
navigate('/invoices', { state: { tab: 'subcontractor-po' } });
|
||||
navigate('/finances', { state: { tab: 'subcontractor-po' } });
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
@@ -149,7 +114,7 @@ export default function SubcontractorPODetail() {
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
if (loading) return <Layout><PageLoader /></Layout>;
|
||||
if (!po) return <Layout><p>Purchase order not found.</p></Layout>;
|
||||
|
||||
const sortedItems = (po.items || []).slice().sort((a, b) => Number(a.sort_order || 0) - Number(b.sort_order || 0));
|
||||
@@ -163,7 +128,7 @@ export default function SubcontractorPODetail() {
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<button className="back-link" onClick={() => navigate('/invoices', { state: { tab: 'subcontractor-po' } })}>← Back to Subcontractor PO</button>
|
||||
<button className="back-link" onClick={() => navigate('/finances', { state: { tab: 'subcontractor-po' } })}>← Back to Subcontractor PO</button>
|
||||
|
||||
<div className="page-header">
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user