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

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