Add Project Files section and show company name for external users on project detail
This commit is contained in:
+17
-139
@@ -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,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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user