Session 2026-05-30: tasks/projects unification, code consolidation, file renames
- Unified Tasks page: 3 role render blocks → 1, normalized row shape, single renderRow/sort/tabs - Fixed role scoping: external = all tasks in member projects, client = all tasks in company projects - Fixed client bug: was scoped by submitted_by, now company→project→tasks - Aligned dashboard client scope to match tasks page - Hot tasks dashboard: now filters to active statuses only (not completed/invoiced/paid) - Removed Projects.jsx (dead), fixed /project/ → /projects/ links - Renamed all page files to match folder path (Team*, External*, Client* prefixes) - Renamed: RequestsPage→Tasks, Settings→Profile, CompaniesPage→Companies, ProjectDetailPage→ProjectDetail - Dropped dead fetches from Tasks page (invoices, invoice_items, subcontractor_invoice_items) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+22
-2
@@ -1,6 +1,6 @@
|
|||||||
import { createClient } from '@supabase/supabase-js';
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
const FB_SOURCE = 'files';
|
const FB_SOURCE = 'srv';
|
||||||
|
|
||||||
function json(res, status, body) {
|
function json(res, status, body) {
|
||||||
res.status(status).setHeader('Content-Type', 'application/json');
|
res.status(status).setHeader('Content-Type', 'application/json');
|
||||||
@@ -263,7 +263,8 @@ function toListResponse(vPath, entries, { readOnly = false } = {}) {
|
|||||||
|
|
||||||
export default async function handler(req, res) {
|
export default async function handler(req, res) {
|
||||||
try {
|
try {
|
||||||
const authHeader = req.headers.authorization || '';
|
const queryToken = String(req.query?.sb_access_token || req.body?.sb_access_token || '').trim();
|
||||||
|
const authHeader = req.headers.authorization || (queryToken ? `Bearer ${queryToken}` : '');
|
||||||
if (!authHeader) return json(res, 401, { error: 'No authorization header' });
|
if (!authHeader) return json(res, 401, { error: 'No authorization header' });
|
||||||
|
|
||||||
const auth = await requirePortalUser(authHeader);
|
const auth = await requirePortalUser(authHeader);
|
||||||
@@ -333,6 +334,25 @@ export default async function handler(req, res) {
|
|||||||
return json(res, 200, { url: downloadUrl, token });
|
return json(res, 200, { url: downloadUrl, token });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.method === 'GET' && action === 'download-blob') {
|
||||||
|
const resolved = toFbPath();
|
||||||
|
if (resolved.virtual) return json(res, 400, { error: 'Cannot download virtual directory' });
|
||||||
|
const token = getToken(config);
|
||||||
|
const downloadUrl = `${config.url}/api/resources/download?source=${FB_SOURCE}&file=${encodeURIComponent(resolved.fbPath)}&auth=${encodeURIComponent(token)}`;
|
||||||
|
const upstream = await fetch(downloadUrl);
|
||||||
|
if (!upstream.ok) {
|
||||||
|
const text = await upstream.text();
|
||||||
|
return json(res, upstream.status, { error: text || `Download failed (${upstream.status})` });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(upstream.status);
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
|
res.setHeader('Content-Type', upstream.headers.get('content-type') || 'application/octet-stream');
|
||||||
|
res.setHeader('Content-Disposition', upstream.headers.get('content-disposition') || `attachment; filename="${basename(resolved.fbPath) || 'download'}"`);
|
||||||
|
const arrayBuffer = await upstream.arrayBuffer();
|
||||||
|
return res.send(Buffer.from(arrayBuffer));
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method === 'POST' && action === 'upload-token') {
|
if (req.method === 'POST' && action === 'upload-token') {
|
||||||
const resolved = toFbPath();
|
const resolved = toFbPath();
|
||||||
if (resolved.virtual) return json(res, 400, { error: 'Cannot upload to virtual directory' });
|
if (resolved.virtual) return json(res, 400, { error: 'Cannot upload to virtual directory' });
|
||||||
|
|||||||
+29
-29
@@ -39,31 +39,31 @@ class ChunkErrorBoundary extends Component {
|
|||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
import PayInvoice from './pages/PayInvoice';
|
import PayInvoice from './pages/PayInvoice';
|
||||||
|
|
||||||
const ProfilePage = lazy(() => import('./pages/Settings'));
|
const ProfilePage = lazy(() => import('./pages/Profile'));
|
||||||
const CompaniesPage = lazy(() => import('./pages/CompaniesPage'));
|
const CompaniesPage = lazy(() => import('./pages/Companies'));
|
||||||
const CompanyDetail = lazy(() => import('./pages/CompanyDetail'));
|
const CompanyDetail = lazy(() => import('./pages/CompanyDetail'));
|
||||||
const Invoices = lazy(() => import('./pages/team/Invoices'));
|
const TeamInvoices = lazy(() => import('./pages/team/TeamInvoices'));
|
||||||
const RequestDetail = lazy(() => import('./pages/RequestDetail'));
|
const RequestDetail = lazy(() => import('./pages/RequestDetail'));
|
||||||
const CreateInvoice = lazy(() => import('./pages/team/CreateInvoice'));
|
const TeamCreateInvoice = lazy(() => import('./pages/team/TeamCreateInvoice'));
|
||||||
const CreateSubcontractorPO = lazy(() => import('./pages/team/CreateSubcontractorPO'));
|
const TeamCreateSubcontractorPO = lazy(() => import('./pages/team/TeamCreateSubcontractorPO'));
|
||||||
const InvoiceDetail = lazy(() => import('./pages/team/InvoiceDetail'));
|
const TeamInvoiceDetail = lazy(() => import('./pages/team/TeamInvoiceDetail'));
|
||||||
const SubcontractorPODetail = lazy(() => import('./pages/team/SubcontractorPODetail'));
|
const TeamSubcontractorPODetail = lazy(() => import('./pages/team/TeamSubcontractorPODetail'));
|
||||||
const SubInvoiceDetail = lazy(() => import('./pages/team/SubInvoiceDetail'));
|
const TeamSubInvoiceDetail = lazy(() => import('./pages/team/TeamSubInvoiceDetail'));
|
||||||
const SurveyMaker = lazy(() => import('./pages/SurveyMaker'));
|
const SurveyMaker = lazy(() => import('./pages/SurveyMaker'));
|
||||||
const BrandBook = lazy(() => import('./pages/BrandBook'));
|
const BrandBook = lazy(() => import('./pages/BrandBook'));
|
||||||
const Converters = lazy(() => import('./pages/Converters'));
|
const Converters = lazy(() => import('./pages/Converters'));
|
||||||
const FileSharing = lazy(() => import('./pages/FileSharing'));
|
const FileSharing = lazy(() => import('./pages/FileSharing'));
|
||||||
const FourgePasswords = lazy(() => import('./pages/team/FourgePasswords'));
|
const TeamFourgePasswords = lazy(() => import('./pages/team/TeamFourgePasswords'));
|
||||||
const MyPurchaseOrders = lazy(() => import('./pages/external/MyPurchaseOrders'));
|
const ExternalMyPurchaseOrders = lazy(() => import('./pages/external/ExternalMyPurchaseOrders'));
|
||||||
const ExternalMyInvoices = lazy(() => import('./pages/external/MyInvoices'));
|
const ExternalMyInvoices = lazy(() => import('./pages/external/ExternalMyInvoices'));
|
||||||
const ExternalMyInvoiceDetail = lazy(() => import('./pages/external/MyInvoiceDetail'));
|
const ExternalMyInvoiceDetail = lazy(() => import('./pages/external/ExternalMyInvoiceDetail'));
|
||||||
const ExternalMyInvoiceCreate = lazy(() => import('./pages/external/MyInvoiceCreate'));
|
const ExternalMyInvoiceCreate = lazy(() => import('./pages/external/ExternalMyInvoiceCreate'));
|
||||||
const ProjectDetailPage = lazy(() => import('./pages/ProjectDetailPage'));
|
const ProjectDetailPage = lazy(() => import('./pages/ProjectDetail'));
|
||||||
const TeamDashboard = lazy(() => import('./pages/team/TeamDashboard'));
|
const TeamDashboard = lazy(() => import('./pages/team/TeamDashboard'));
|
||||||
const RequestsPage = lazy(() => import('./pages/RequestsPage'));
|
const RequestsPage = lazy(() => import('./pages/Tasks'));
|
||||||
const MyInvoices = lazy(() => import('./pages/client/MyInvoices'));
|
const ClientMyInvoices = lazy(() => import('./pages/client/ClientMyInvoices'));
|
||||||
const NewRequest = lazy(() => import('./pages/client/NewRequest'));
|
const ClientNewRequest = lazy(() => import('./pages/client/ClientNewRequest'));
|
||||||
const NewProject = lazy(() => import('./pages/client/NewProject'));
|
const ClientNewProject = lazy(() => import('./pages/client/ClientNewProject'));
|
||||||
|
|
||||||
function RedirectProjectDetail() {
|
function RedirectProjectDetail() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
@@ -103,21 +103,21 @@ export default function App() {
|
|||||||
<Route path="/tasks" element={<ProtectedRoute role={['team', 'external', 'client']}><RequestsPage /></ProtectedRoute>} />
|
<Route path="/tasks" element={<ProtectedRoute role={['team', 'external', 'client']}><RequestsPage /></ProtectedRoute>} />
|
||||||
<Route path="/requests" element={<Navigate to="/tasks" replace />} />
|
<Route path="/requests" element={<Navigate to="/tasks" replace />} />
|
||||||
<Route path="/team-projects" element={<Navigate to="/tasks" replace />} />
|
<Route path="/team-projects" element={<Navigate to="/tasks" replace />} />
|
||||||
<Route path="/invoices" element={<ProtectedRoute role="team"><Invoices /></ProtectedRoute>} />
|
<Route path="/invoices" element={<ProtectedRoute role="team"><TeamInvoices /></ProtectedRoute>} />
|
||||||
<Route path="/invoices/new" element={<ProtectedRoute role="team"><CreateInvoice /></ProtectedRoute>} />
|
<Route path="/invoices/new" element={<ProtectedRoute role="team"><TeamCreateInvoice /></ProtectedRoute>} />
|
||||||
<Route path="/subcontractor-pos/new" element={<ProtectedRoute role="team"><CreateSubcontractorPO /></ProtectedRoute>} />
|
<Route path="/subcontractor-pos/new" element={<ProtectedRoute role="team"><TeamCreateSubcontractorPO /></ProtectedRoute>} />
|
||||||
<Route path="/invoices/:id" element={<ProtectedRoute role="team"><InvoiceDetail /></ProtectedRoute>} />
|
<Route path="/invoices/:id" element={<ProtectedRoute role="team"><TeamInvoiceDetail /></ProtectedRoute>} />
|
||||||
<Route path="/subcontractor-pos/:id" element={<ProtectedRoute role="team"><SubcontractorPODetail /></ProtectedRoute>} />
|
<Route path="/subcontractor-pos/:id" element={<ProtectedRoute role="team"><TeamSubcontractorPODetail /></ProtectedRoute>} />
|
||||||
<Route path="/sub-invoices/:id" element={<ProtectedRoute role="team"><SubInvoiceDetail /></ProtectedRoute>} />
|
<Route path="/sub-invoices/:id" element={<ProtectedRoute role="team"><TeamSubInvoiceDetail /></ProtectedRoute>} />
|
||||||
<Route path="/survey-maker" element={<ProtectedRoute role={['team', 'external']}><SurveyMaker /></ProtectedRoute>} />
|
<Route path="/survey-maker" element={<ProtectedRoute role={['team', 'external']}><SurveyMaker /></ProtectedRoute>} />
|
||||||
<Route path="/brand-book" element={<ProtectedRoute role={['team', 'external']}><BrandBook /></ProtectedRoute>} />
|
<Route path="/brand-book" element={<ProtectedRoute role={['team', 'external']}><BrandBook /></ProtectedRoute>} />
|
||||||
<Route path="/converters" element={<ProtectedRoute role={['team', 'external']}><Converters /></ProtectedRoute>} />
|
<Route path="/converters" element={<ProtectedRoute role={['team', 'external']}><Converters /></ProtectedRoute>} />
|
||||||
<Route path="/file-sharing" element={<ProtectedRoute role={['team', 'external', 'client']}><FileSharing /></ProtectedRoute>} />
|
<Route path="/file-sharing" element={<ProtectedRoute role={['team', 'external', 'client']}><FileSharing /></ProtectedRoute>} />
|
||||||
<Route path="/file-uploads" element={<Navigate to="/file-sharing" replace />} />
|
<Route path="/file-uploads" element={<Navigate to="/file-sharing" replace />} />
|
||||||
<Route path="/fourge-passwords" element={<ProtectedRoute role="team"><FourgePasswords /></ProtectedRoute>} />
|
<Route path="/fourge-passwords" element={<ProtectedRoute role="team"><TeamFourgePasswords /></ProtectedRoute>} />
|
||||||
<Route path="/server-status" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/server-status" element={<Navigate to="/dashboard" replace />} />
|
||||||
<Route path="/assigned-requests" element={<Navigate to="/tasks" replace />} />
|
<Route path="/assigned-requests" element={<Navigate to="/tasks" replace />} />
|
||||||
<Route path="/my-purchase-orders" element={<ProtectedRoute role="external"><MyPurchaseOrders /></ProtectedRoute>} />
|
<Route path="/my-purchase-orders" element={<ProtectedRoute role="external"><ExternalMyPurchaseOrders /></ProtectedRoute>} />
|
||||||
<Route path="/my-projects-sub" element={<Navigate to="/tasks" replace />} />
|
<Route path="/my-projects-sub" element={<Navigate to="/tasks" replace />} />
|
||||||
<Route path="/my-invoices-sub" element={<ProtectedRoute role="external"><ExternalMyInvoices /></ProtectedRoute>} />
|
<Route path="/my-invoices-sub" element={<ProtectedRoute role="external"><ExternalMyInvoices /></ProtectedRoute>} />
|
||||||
<Route path="/my-invoices-sub/new" element={<ProtectedRoute role="external"><ExternalMyInvoiceCreate /></ProtectedRoute>} />
|
<Route path="/my-invoices-sub/new" element={<ProtectedRoute role="external"><ExternalMyInvoiceCreate /></ProtectedRoute>} />
|
||||||
@@ -133,9 +133,9 @@ export default function App() {
|
|||||||
<Route path="/my-requests/:id" element={<RedirectRequestDetail />} />
|
<Route path="/my-requests/:id" element={<RedirectRequestDetail />} />
|
||||||
<Route path="/my-projects" element={<Navigate to="/tasks" replace />} />
|
<Route path="/my-projects" element={<Navigate to="/tasks" replace />} />
|
||||||
<Route path="/my-projects/:id" element={<RedirectProjectDetail />} />
|
<Route path="/my-projects/:id" element={<RedirectProjectDetail />} />
|
||||||
<Route path="/my-invoices" element={<ProtectedRoute role="client"><MyInvoices /></ProtectedRoute>} />
|
<Route path="/my-invoices" element={<ProtectedRoute role="client"><ClientMyInvoices /></ProtectedRoute>} />
|
||||||
<Route path="/new-request" element={<ProtectedRoute role="client"><NewRequest /></ProtectedRoute>} />
|
<Route path="/new-request" element={<ProtectedRoute role="client"><ClientNewRequest /></ProtectedRoute>} />
|
||||||
<Route path="/new-project" element={<ProtectedRoute role="client"><NewProject /></ProtectedRoute>} />
|
<Route path="/new-project" element={<ProtectedRoute role="client"><ClientNewProject /></ProtectedRoute>} />
|
||||||
|
|
||||||
<Route path="/pay/:id" element={<PayInvoice />} />
|
<Route path="/pay/:id" element={<PayInvoice />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|||||||
@@ -1,32 +1,43 @@
|
|||||||
export default function PageLoader() {
|
export default function PageLoader({ label = 'Loading', progress = null, scope = 'page' }) {
|
||||||
|
const hasProgress = typeof progress === 'number' && Number.isFinite(progress);
|
||||||
|
const isContainerScope = scope === 'container';
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'fixed',
|
position: isContainerScope ? 'absolute' : 'fixed',
|
||||||
inset: 0,
|
inset: 0,
|
||||||
zIndex: 1200,
|
zIndex: isContainerScope ? 20 : 1200,
|
||||||
background: 'rgba(0,0,0,0.58)',
|
background: 'var(--overlay-scrim)',
|
||||||
backdropFilter: 'blur(6px)',
|
backdropFilter: 'blur(6px)',
|
||||||
WebkitBackdropFilter: 'blur(6px)',
|
WebkitBackdropFilter: 'blur(6px)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
padding: 24,
|
padding: 24,
|
||||||
|
borderRadius: isContainerScope ? 'inherit' : 0,
|
||||||
}}>
|
}}>
|
||||||
<div style={{
|
<div style={{
|
||||||
background: 'var(--card-bg, rgba(255,255,255,0.06))',
|
background: 'var(--popup-bg)',
|
||||||
border: '1px solid var(--border, rgba(255,255,255,0.1))',
|
border: '1px solid var(--border)',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
padding: '18px 21px',
|
padding: '18px 21px',
|
||||||
backdropFilter: 'blur(12px)',
|
backdropFilter: 'blur(12px)',
|
||||||
WebkitBackdropFilter: 'blur(12px)',
|
WebkitBackdropFilter: 'blur(12px)',
|
||||||
width: 'min(320px, 100%)',
|
width: 'min(320px, 100%)',
|
||||||
}}>
|
}}>
|
||||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary, rgba(255,255,255,0.5))', textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 14 }}>
|
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 14 }}>
|
||||||
Loading
|
{label}
|
||||||
</div>
|
|
||||||
<div style={{ height: 4, borderRadius: 2, background: 'var(--border, rgba(255,255,255,0.1))', overflow: 'hidden' }}>
|
|
||||||
<div className="file-browser-progress-bar indeterminate" style={{ height: '100%', borderRadius: 2, background: 'var(--accent, #F5A523)' }} />
|
|
||||||
</div>
|
</div>
|
||||||
|
<>
|
||||||
|
<div style={{ height: 4, borderRadius: 2, background: 'var(--card-bg-2)', overflow: 'hidden', marginBottom: hasProgress ? 8 : 0 }}>
|
||||||
|
<div
|
||||||
|
className={hasProgress ? undefined : 'file-browser-progress-bar indeterminate'}
|
||||||
|
style={{ height: '100%', borderRadius: 2, background: 'var(--accent)', width: hasProgress ? `${progress}%` : undefined, transition: 'width 0.2s ease' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{hasProgress && (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-secondary)', textAlign: 'right' }}>{progress}%</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import FileAttachment from './FileAttachment';
|
|||||||
import { addDaysToDateOnly, getTodayDateOnlyEST } from '../lib/dates';
|
import { addDaysToDateOnly, getTodayDateOnlyEST } from '../lib/dates';
|
||||||
|
|
||||||
const defaultDeadline = () => addDaysToDateOnly(getTodayDateOnlyEST(), 3);
|
const defaultDeadline = () => addDaysToDateOnly(getTodayDateOnlyEST(), 3);
|
||||||
|
const modalLabelStyle = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 6 };
|
||||||
|
const modalInputStyle = { fontSize: 13, padding: '0 5px', textAlign: 'left', lineHeight: 1 };
|
||||||
|
const modalTextAreaStyle = { ...modalInputStyle, minHeight: 100, padding: '8px 5px', lineHeight: 1.4 };
|
||||||
const emptyForm = (companyId = '') => ({
|
const emptyForm = (companyId = '') => ({
|
||||||
companyId,
|
companyId,
|
||||||
project: '',
|
project: '',
|
||||||
@@ -123,11 +126,12 @@ export default function RequestForm({
|
|||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
{showCompanySelect && (
|
{showCompanySelect && (
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Company *</label>
|
<label style={modalLabelStyle}>Company *</label>
|
||||||
<select
|
<select
|
||||||
value={form.companyId}
|
value={form.companyId}
|
||||||
onChange={e => setForm(f => ({ ...f, companyId: e.target.value }))}
|
onChange={e => setForm(f => ({ ...f, companyId: e.target.value }))}
|
||||||
required
|
required
|
||||||
|
style={modalInputStyle}
|
||||||
>
|
>
|
||||||
<option value="">Select company...</option>
|
<option value="">Select company...</option>
|
||||||
{companies.map(co => <option key={co.id} value={co.id}>{co.name}</option>)}
|
{companies.map(co => <option key={co.id} value={co.id}>{co.name}</option>)}
|
||||||
@@ -136,7 +140,7 @@ export default function RequestForm({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Project *</label>
|
<label style={modalLabelStyle}>Project *</label>
|
||||||
{isTypingProject ? (
|
{isTypingProject ? (
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<input
|
<input
|
||||||
@@ -146,13 +150,13 @@ export default function RequestForm({
|
|||||||
onChange={e => setNewProjectName(e.target.value)}
|
onChange={e => setNewProjectName(e.target.value)}
|
||||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleAddProject(); } }}
|
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleAddProject(); } }}
|
||||||
autoFocus
|
autoFocus
|
||||||
style={{ flex: 1 }}
|
style={{ ...modalInputStyle, flex: 1 }}
|
||||||
/>
|
/>
|
||||||
<button type="button" className="btn btn-primary btn-sm" onClick={handleAddProject} disabled={!newProjectName.trim()}>Add</button>
|
<button type="button" className="btn btn-outline" onClick={handleAddProject} disabled={!newProjectName.trim()}>Add</button>
|
||||||
<button type="button" className="btn btn-outline btn-sm" onClick={() => { setIsTypingProject(false); setNewProjectName(''); }}>Cancel</button>
|
<button type="button" className="btn btn-outline" onClick={() => { setIsTypingProject(false); setNewProjectName(''); }}>Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<select value={form.project} onChange={handleProjectSelect} required disabled={showCompanySelect && !companyId}>
|
<select value={form.project} onChange={handleProjectSelect} required disabled={showCompanySelect && !companyId} style={modalInputStyle}>
|
||||||
<option value="">{showCompanySelect && !companyId ? 'Select company first' : 'Select a project...'}</option>
|
<option value="">{showCompanySelect && !companyId ? 'Select company first' : 'Select a project...'}</option>
|
||||||
{allProjectNames.map(name => <option key={name} value={name}>{name}</option>)}
|
{allProjectNames.map(name => <option key={name} value={name}>{name}</option>)}
|
||||||
{(!showCompanySelect || companyId) && <option value="__new__">+ Create new project...</option>}
|
{(!showCompanySelect || companyId) && <option value="__new__">+ Create new project...</option>}
|
||||||
@@ -162,20 +166,20 @@ export default function RequestForm({
|
|||||||
|
|
||||||
<div className="grid-2">
|
<div className="grid-2">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Service Type *</label>
|
<label style={modalLabelStyle}>Service Type *</label>
|
||||||
<select value={form.serviceType} onChange={set('serviceType')} required>
|
<select value={form.serviceType} onChange={set('serviceType')} required style={modalInputStyle}>
|
||||||
<option value="">Select service...</option>
|
<option value="">Select service...</option>
|
||||||
{serviceTypes.map(s => <option key={s} value={s}>{s}</option>)}
|
{serviceTypes.map(s => <option key={s} value={s}>{s}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Desired Deadline</label>
|
<label style={modalLabelStyle}>Desired Deadline</label>
|
||||||
<input type="date" value={form.deadline} onChange={set('deadline')} />
|
<input type="date" value={form.deadline} onChange={set('deadline')} style={modalInputStyle} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group" style={{ marginTop: -4 }}>
|
<div className="form-group" style={{ marginTop: -4 }}>
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: 10, fontWeight: 500, cursor: 'pointer' }}>
|
<label style={{ ...modalLabelStyle, display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', marginBottom: 0 }}>
|
||||||
<input type="checkbox" checked={form.isHot} onChange={e => setForm(f => ({ ...f, isHot: e.target.checked }))} />
|
<input type="checkbox" checked={form.isHot} onChange={e => setForm(f => ({ ...f, isHot: e.target.checked }))} />
|
||||||
<span>Mark as Hot</span>
|
<span>Mark as Hot</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -183,8 +187,8 @@ export default function RequestForm({
|
|||||||
|
|
||||||
{showRequester && (
|
{showRequester && (
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Requested By *</label>
|
<label style={modalLabelStyle}>Requested By *</label>
|
||||||
<select value={form.requestedBy} onChange={set('requestedBy')} disabled={!companyId} required>
|
<select value={form.requestedBy} onChange={set('requestedBy')} disabled={!companyId} required style={modalInputStyle}>
|
||||||
<option value="">{companyId ? 'Select requester...' : 'Select company first'}</option>
|
<option value="">{companyId ? 'Select requester...' : 'Select company first'}</option>
|
||||||
{requesterOptions.map(user => (
|
{requesterOptions.map(user => (
|
||||||
<option key={user.id} value={user.id}>{user.name}{user.email ? ` (${user.email})` : ''}</option>
|
<option key={user.id} value={user.id}>{user.name}{user.email ? ` (${user.email})` : ''}</option>
|
||||||
@@ -194,21 +198,21 @@ export default function RequestForm({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Request Title *</label>
|
<label style={modalLabelStyle}>Request Title *</label>
|
||||||
<input type="text" placeholder="e.g. City, State or Site Name" value={form.title} onChange={set('title')} required />
|
<input type="text" placeholder="e.g. City, State or Site Name" value={form.title} onChange={set('title')} required style={modalInputStyle} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Description *</label>
|
<label style={modalLabelStyle}>Description *</label>
|
||||||
<textarea placeholder="Notes on the request..." value={form.description} onChange={set('description')} style={{ minHeight: 100 }} required />
|
<textarea placeholder="Notes on the request..." value={form.description} onChange={set('description')} style={modalTextAreaStyle} required />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FileAttachment files={files} onChange={setFiles} />
|
<FileAttachment files={files} onChange={setFiles} />
|
||||||
|
|
||||||
{error && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>⚠ {error}</div>}
|
{error && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>⚠ {error}</div>}
|
||||||
|
|
||||||
<div className="action-buttons">
|
<div className="action-buttons" style={{ justifyContent: 'flex-end' }}>
|
||||||
<button type="submit" className="btn btn-primary" disabled={saving}>
|
<button type="submit" className="btn btn-outline" disabled={saving}>
|
||||||
{saving ? 'Submitting...' : submitLabel}
|
{saving ? 'Submitting...' : submitLabel}
|
||||||
</button>
|
</button>
|
||||||
{onCancel && <button type="button" className="btn btn-outline" onClick={onCancel}>Cancel</button>}
|
{onCancel && <button type="button" className="btn btn-outline" onClick={onCancel}>Cancel</button>}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const labels = {
|
|||||||
|
|
||||||
export default function StatusBadge({ status }) {
|
export default function StatusBadge({ status }) {
|
||||||
return (
|
return (
|
||||||
<span className={`badge badge-${status}`}>
|
<span className={`badge badge-status badge-${status}`}>
|
||||||
{labels[status] || status}
|
{labels[status] || status}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -37,6 +37,8 @@
|
|||||||
--avatar-inner-ring: #111111;
|
--avatar-inner-ring: #111111;
|
||||||
--danger: #ef4444;
|
--danger: #ef4444;
|
||||||
--success: #22c55e;
|
--success: #22c55e;
|
||||||
|
--table-scroll-thumb: rgba(0,0,0,0.1);
|
||||||
|
--table-scroll-thumb-hover: rgba(0,0,0,0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes ui-fade-up {
|
@keyframes ui-fade-up {
|
||||||
@@ -66,6 +68,8 @@
|
|||||||
--overlay-scrim: rgba(255,255,255,0.72);
|
--overlay-scrim: rgba(255,255,255,0.72);
|
||||||
--popup-shadow: 0 16px 42px rgba(0,0,0,0.18);
|
--popup-shadow: 0 16px 42px rgba(0,0,0,0.18);
|
||||||
--avatar-inner-ring: #ffffff;
|
--avatar-inner-ring: #ffffff;
|
||||||
|
--table-scroll-thumb: rgba(0,0,0,0.1);
|
||||||
|
--table-scroll-thumb-hover: rgba(0,0,0,0.2);
|
||||||
}
|
}
|
||||||
[data-theme="light"] input[type="text"],
|
[data-theme="light"] input[type="text"],
|
||||||
[data-theme="light"] input[type="email"],
|
[data-theme="light"] input[type="email"],
|
||||||
@@ -1100,6 +1104,24 @@ textarea {
|
|||||||
|
|
||||||
/* Table */
|
/* Table */
|
||||||
.table-wrapper { overflow-x: auto; border-radius: 4px; border: 1px solid var(--border); background: var(--card-bg); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); }
|
.table-wrapper { overflow-x: auto; border-radius: 4px; border: 1px solid var(--border); background: var(--card-bg); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); }
|
||||||
|
.table-wrapper {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--table-scroll-thumb) transparent;
|
||||||
|
}
|
||||||
|
.table-wrapper::-webkit-scrollbar {
|
||||||
|
width: 3px;
|
||||||
|
height: 3px;
|
||||||
|
}
|
||||||
|
.table-wrapper::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.table-wrapper::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--table-scroll-thumb);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
.table-wrapper::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--table-scroll-thumb-hover);
|
||||||
|
}
|
||||||
table { width: 100%; border-collapse: collapse; }
|
table { width: 100%; border-collapse: collapse; }
|
||||||
th { text-align: left; padding: 12px 16px; font-size: 10px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.8px; color: var(--text-muted); background: rgba(255,255,255,0.07); border-bottom: 1px solid var(--border); }
|
th { text-align: left; padding: 12px 16px; font-size: 10px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.8px; color: var(--text-muted); background: rgba(255,255,255,0.07); border-bottom: 1px solid var(--border); }
|
||||||
td { padding: 14px 16px; border-bottom: 1px solid var(--border); font-size: 13px; color: var(--text-primary); }
|
td { padding: 14px 16px; border-bottom: 1px solid var(--border); font-size: 13px; color: var(--text-primary); }
|
||||||
@@ -1127,6 +1149,48 @@ tr:hover td { background: rgba(255,255,255,0.02); }
|
|||||||
.table-scroll-fade {
|
.table-scroll-fade {
|
||||||
position: static;
|
position: static;
|
||||||
}
|
}
|
||||||
|
.table-scroll-hidden {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--table-scroll-thumb) transparent;
|
||||||
|
}
|
||||||
|
.table-scroll-hidden::-webkit-scrollbar {
|
||||||
|
width: 3px;
|
||||||
|
height: 3px;
|
||||||
|
}
|
||||||
|
.table-scroll-hidden::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.table-scroll-hidden::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--table-scroll-thumb);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
.table-scroll-hidden::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--table-scroll-thumb-hover);
|
||||||
|
}
|
||||||
|
.scrollbar-thin-theme.table-scroll-hidden {
|
||||||
|
scrollbar-color: var(--table-scroll-thumb) transparent !important;
|
||||||
|
}
|
||||||
|
.scrollbar-thin-theme.table-scroll-hidden:hover,
|
||||||
|
.scrollbar-thin-theme.table-scroll-hidden:focus-within {
|
||||||
|
scrollbar-color: var(--table-scroll-thumb-hover) transparent !important;
|
||||||
|
}
|
||||||
|
.scrollbar-thin-theme.table-scroll-hidden::-webkit-scrollbar {
|
||||||
|
width: 3px !important;
|
||||||
|
height: 3px !important;
|
||||||
|
}
|
||||||
|
.scrollbar-thin-theme.table-scroll-hidden::-webkit-scrollbar-thumb,
|
||||||
|
.scrollbar-thin-theme.table-scroll-hidden:hover::-webkit-scrollbar-thumb,
|
||||||
|
.scrollbar-thin-theme.table-scroll-hidden:focus-within::-webkit-scrollbar-thumb,
|
||||||
|
.scrollbar-thin-theme.table-scroll-hidden::-webkit-scrollbar-thumb:active,
|
||||||
|
.scrollbar-thin-theme.table-scroll-hidden::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--table-scroll-thumb) !important;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 0 !important;
|
||||||
|
}
|
||||||
|
.project-table-scroll {
|
||||||
|
padding-right: 8px;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
.dashboard-inline-link {
|
.dashboard-inline-link {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -1203,6 +1267,17 @@ select option { background: #222; color: #fff; }
|
|||||||
.empty-state { text-align: center; padding: 56px 20px; color: var(--text-secondary); }
|
.empty-state { text-align: center; padding: 56px 20px; color: var(--text-secondary); }
|
||||||
.empty-state-icon { display: none; }
|
.empty-state-icon { display: none; }
|
||||||
.empty-state h3 { font-size: 15px; font-weight: 400; color: var(--text-primary); margin-bottom: 6px; }
|
.empty-state h3 { font-size: 15px; font-weight: 400; color: var(--text-primary); margin-bottom: 6px; }
|
||||||
|
.card-empty-center {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 120px;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
||||||
.version-timeline { display: flex; flex-direction: column; gap: 12px; }
|
.version-timeline { display: flex; flex-direction: column; gap: 12px; }
|
||||||
.version-item { border: 1px solid var(--border); border-radius: 4px; padding: 16px; background: var(--card-bg); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); }
|
.version-item { border: 1px solid var(--border); border-radius: 4px; padding: 16px; background: var(--card-bg); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); }
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
export const popupOverlayStyle = {
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 1200,
|
||||||
|
background: 'var(--overlay-scrim)',
|
||||||
|
backdropFilter: 'blur(6px)',
|
||||||
|
WebkitBackdropFilter: 'blur(6px)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 24,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const popupSurfaceStyle = {
|
||||||
|
background: 'var(--popup-bg)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '18px 21px',
|
||||||
|
backdropFilter: 'blur(12px)',
|
||||||
|
WebkitBackdropFilter: 'blur(12px)',
|
||||||
|
boxShadow: 'var(--popup-shadow)',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const popupMenuStyle = {
|
||||||
|
background: 'var(--popup-bg)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 8,
|
||||||
|
backdropFilter: 'blur(12px)',
|
||||||
|
WebkitBackdropFilter: 'blur(12px)',
|
||||||
|
boxShadow: 'var(--popup-shadow)',
|
||||||
|
};
|
||||||
+5
-16
@@ -6,6 +6,7 @@ import { useSortable } from '../hooks/useSortable';
|
|||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import { generateBrandBookEditorPDF } from '../lib/brandBookEditor';
|
import { generateBrandBookEditorPDF } from '../lib/brandBookEditor';
|
||||||
import { cleanupBrandBookStorage } from '../lib/deleteHelpers';
|
import { cleanupBrandBookStorage } from '../lib/deleteHelpers';
|
||||||
|
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
|
||||||
|
|
||||||
const BUCKET = 'brand-books';
|
const BUCKET = 'brand-books';
|
||||||
|
|
||||||
@@ -2350,14 +2351,10 @@ function DimensionEditorModal({ sourceImage, onApply, onCancel }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{ ...popupOverlayStyle, zIndex: 9999, padding: 20 }}
|
||||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.72)',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
zIndex: 9999, padding: 20,
|
|
||||||
}}
|
|
||||||
onClick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
|
onClick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
|
||||||
>
|
>
|
||||||
<div style={{ background: 'var(--card-bg)', borderRadius: 4, display: 'flex', flexDirection: 'column', maxWidth: '98vw', maxHeight: '96vh', overflow: 'hidden', boxShadow: '0 24px 64px rgba(0,0,0,0.55)' }}>
|
<div style={{ ...popupSurfaceStyle, borderRadius: 8, display: 'flex', flexDirection: 'column', maxWidth: '98vw', maxHeight: '96vh', overflow: 'hidden', padding: 0 }}>
|
||||||
<div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
<div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 14, fontWeight: 400, color: 'var(--text-primary)' }}>Sign Detail Dimensions</div>
|
<div style={{ fontSize: 14, fontWeight: 400, color: 'var(--text-primary)' }}>Sign Detail Dimensions</div>
|
||||||
@@ -3437,18 +3434,10 @@ function PhotoEditorModal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{ ...popupOverlayStyle, zIndex: 9999, padding: 20 }}
|
||||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.72)',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
zIndex: 9999, padding: 20,
|
|
||||||
}}
|
|
||||||
onClick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
|
onClick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
|
||||||
>
|
>
|
||||||
<div style={{
|
<div style={{ ...popupSurfaceStyle, borderRadius: 8, display: 'flex', flexDirection: 'column', maxWidth: '98vw', maxHeight: '96vh', overflow: 'hidden', padding: 0 }}>
|
||||||
background: 'var(--card-bg)', borderRadius: 4, display: 'flex', flexDirection: 'column',
|
|
||||||
maxWidth: '98vw', maxHeight: '96vh', overflow: 'hidden',
|
|
||||||
boxShadow: '0 24px 64px rgba(0,0,0,0.55)',
|
|
||||||
}}>
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
<div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -650,7 +650,7 @@ function _UnusedClientCompanies() {
|
|||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-title">People</div>
|
<div className="card-title">People</div>
|
||||||
{members.length === 0 ? (
|
{members.length === 0 ? (
|
||||||
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>No members found.</p>
|
<div className="card-empty-center">No members found.</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
{members.map((member, i) => (
|
{members.map((member, i) => (
|
||||||
+73
-71
@@ -1,9 +1,11 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import JSZip from 'jszip';
|
import JSZip from 'jszip';
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
|
import PageLoader from '../components/PageLoader';
|
||||||
import SortTh from '../components/SortTh';
|
import SortTh from '../components/SortTh';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { popupMenuStyle } from '../lib/popupStyles';
|
||||||
|
|
||||||
const CARD = {
|
const CARD = {
|
||||||
background: 'var(--card-bg)',
|
background: 'var(--card-bg)',
|
||||||
@@ -108,6 +110,43 @@ function FileIcon({ name, isDir }) {
|
|||||||
|
|
||||||
const FBQ_FN = `${import.meta.env.VITE_SUPABASE_URL}/functions/v1/fbq-proxy`;
|
const FBQ_FN = `${import.meta.env.VITE_SUPABASE_URL}/functions/v1/fbq-proxy`;
|
||||||
|
|
||||||
|
async function downloadViaLocalFilebrowser(path, session) {
|
||||||
|
if (!session?.access_token) throw new Error('Missing session token for download.');
|
||||||
|
const response = await fetch(`/api/filebrowser?action=download-blob&path=${encodeURIComponent(path)}&sb_access_token=${encodeURIComponent(session.access_token)}`, {
|
||||||
|
headers: { Authorization: `Bearer ${session?.access_token ?? ''}` },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(text || `Error ${response.status}`);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadViaApiToken(path, filename) {
|
||||||
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
if (!session?.access_token) throw new Error('Missing session token for download.');
|
||||||
|
const metaResponse = await fetch(`/api/filebrowser?action=download&path=${encodeURIComponent(path)}&sb_access_token=${encodeURIComponent(session.access_token)}`, {
|
||||||
|
headers: { Authorization: `Bearer ${session.access_token}` },
|
||||||
|
});
|
||||||
|
if (!metaResponse.ok) {
|
||||||
|
const text = await metaResponse.text();
|
||||||
|
throw new Error(text || `Error ${metaResponse.status}`);
|
||||||
|
}
|
||||||
|
const { url, token } = await metaResponse.json();
|
||||||
|
if (!url || !token) throw new Error('Download URL unavailable.');
|
||||||
|
const urlObj = new URL(url, window.location.origin);
|
||||||
|
urlObj.searchParams.set('auth', token);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = urlObj.toString();
|
||||||
|
link.download = filename;
|
||||||
|
link.target = '_blank';
|
||||||
|
link.rel = 'noopener noreferrer';
|
||||||
|
link.style.display = 'none';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
}
|
||||||
|
|
||||||
async function fbqProxy(op, path, extra = {}) {
|
async function fbqProxy(op, path, extra = {}) {
|
||||||
const { data: { session } } = await supabase.auth.getSession();
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
const headers = {
|
const headers = {
|
||||||
@@ -121,6 +160,18 @@ async function fbqProxy(op, path, extra = {}) {
|
|||||||
headers,
|
headers,
|
||||||
body: extra.body ?? undefined,
|
body: extra.body ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!response.ok && op === 'download') {
|
||||||
|
const text = await response.text();
|
||||||
|
const shouldFallbackToLocalDownload =
|
||||||
|
response.status === 404
|
||||||
|
|| (response.status === 400 && text.toLowerCase().includes('no files specified'));
|
||||||
|
if (shouldFallbackToLocalDownload) {
|
||||||
|
return downloadViaLocalFilebrowser(path, session);
|
||||||
|
}
|
||||||
|
throw new Error(text || `Error ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
throw new Error(text || `Error ${response.status}`);
|
throw new Error(text || `Error ${response.status}`);
|
||||||
@@ -148,8 +199,11 @@ const TREE_TOGGLE_W = 18;
|
|||||||
|
|
||||||
export default function FileSharing() {
|
export default function FileSharing() {
|
||||||
const { currentUser } = useAuth();
|
const { currentUser } = useAuth();
|
||||||
const home = rootPath(currentUser);
|
const accessScope = useMemo(() => ({
|
||||||
const isTeam = currentUser?.role === 'team';
|
home: rootPath(currentUser),
|
||||||
|
canManage: currentUser?.role === 'team',
|
||||||
|
}), [currentUser]);
|
||||||
|
const home = accessScope.home;
|
||||||
const pinsStorageKey = useMemo(() => `fbq_pins:${currentUser?.id ?? 'anon'}`, [currentUser?.id]);
|
const pinsStorageKey = useMemo(() => `fbq_pins:${currentUser?.id ?? 'anon'}`, [currentUser?.id]);
|
||||||
|
|
||||||
const [path, setPath] = useState(home);
|
const [path, setPath] = useState(home);
|
||||||
@@ -435,8 +489,7 @@ export default function FileSharing() {
|
|||||||
async function handleDownload(entry) {
|
async function handleDownload(entry) {
|
||||||
const nextPath = entry.path ?? (path === '/' ? `/${entry.name}` : `${path}/${entry.name}`);
|
const nextPath = entry.path ?? (path === '/' ? `/${entry.name}` : `${path}/${entry.name}`);
|
||||||
try {
|
try {
|
||||||
const blob = await (await fbqProxy('download', nextPath)).blob();
|
await downloadViaApiToken(nextPath, entry.name);
|
||||||
triggerDownload(URL.createObjectURL(blob), entry.name);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err?.message || 'Download failed.');
|
setError(err?.message || 'Download failed.');
|
||||||
}
|
}
|
||||||
@@ -756,7 +809,7 @@ export default function FileSharing() {
|
|||||||
|
|
||||||
<div style={{ ...CARD, padding: '14px 16px', flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
<div style={{ ...CARD, padding: '14px 16px', flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
<div style={{ ...LABEL, flexShrink: 0 }}>Navigation</div>
|
<div style={{ ...LABEL, flexShrink: 0 }}>Navigation</div>
|
||||||
<div style={{ flex: 1, overflowY: 'auto', marginRight: -6, paddingRight: 6 }}>
|
<div className="table-scroll-hidden" style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden' }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigate(home)}
|
onClick={() => navigate(home)}
|
||||||
@@ -775,6 +828,7 @@ export default function FileSharing() {
|
|||||||
style={{
|
style={{
|
||||||
...CARD,
|
...CARD,
|
||||||
padding: 0,
|
padding: 0,
|
||||||
|
position: 'relative',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
@@ -845,6 +899,7 @@ export default function FileSharing() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
className="table-scroll-hidden"
|
||||||
ref={tableScrollRef}
|
ref={tableScrollRef}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -861,11 +916,11 @@ export default function FileSharing() {
|
|||||||
>
|
>
|
||||||
{error && <div style={{ fontSize: 13, color: 'var(--danger)', paddingBottom: 12 }}>{error}</div>}
|
{error && <div style={{ fontSize: 13, color: 'var(--danger)', paddingBottom: 12 }}>{error}</div>}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '32px 0', textAlign: 'center' }}>Loading...</div>
|
<div className="card-empty-center">Loading...</div>
|
||||||
) : entries.length === 0 ? (
|
) : entries.length === 0 ? (
|
||||||
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '32px 0', textAlign: 'center' }}>This folder is empty.</div>
|
<div className="card-empty-center">This folder is empty.</div>
|
||||||
) : (
|
) : (
|
||||||
<table style={{ width: '100%', tableLayout: 'fixed', borderCollapse: 'collapse' }}>
|
<table className="table-sticky-head" style={{ width: '100%', tableLayout: 'fixed', borderCollapse: 'collapse' }}>
|
||||||
<thead ref={tableHeadRef}>
|
<thead ref={tableHeadRef}>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={{ ...TABLE_TH, width: 32, textAlign: 'center', padding: '0 5px 12px 5px', cursor: 'default' }}>
|
<th style={{ ...TABLE_TH, width: 32, textAlign: 'center', padding: '0 5px 12px 5px', cursor: 'default' }}>
|
||||||
@@ -917,74 +972,21 @@ export default function FileSharing() {
|
|||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{Array.from({ length: fillerRowCount }).map((_, index) => {
|
|
||||||
const rowBg = (sortedEntries.length + index) % 2 === 0 ? 'var(--file-row-alt-bg)' : 'transparent';
|
|
||||||
return (
|
|
||||||
<tr key={`filler-${index}`} aria-hidden="true">
|
|
||||||
<td style={{ ...TABLE_TD, width: 32, background: rowBg }}> </td>
|
|
||||||
<td style={{ ...TABLE_TD, background: rowBg }}> </td>
|
|
||||||
<td style={{ ...TABLE_TD, background: rowBg }}> </td>
|
|
||||||
<td style={{ ...TABLE_TD, background: rowBg }}> </td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{(loading || uploading || downloadingSelection) && (
|
||||||
|
<PageLoader
|
||||||
|
scope="container"
|
||||||
|
label={uploading ? 'Uploading' : downloadingSelection ? 'Downloading' : 'Loading'}
|
||||||
|
progress={uploading ? uploadProgress : downloadingSelection ? downloadProgress : null}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(loading || uploading || downloadingSelection) && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
inset: 0,
|
|
||||||
zIndex: 1200,
|
|
||||||
background: 'rgba(0,0,0,0.58)',
|
|
||||||
backdropFilter: 'blur(6px)',
|
|
||||||
WebkitBackdropFilter: 'blur(6px)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
padding: 24,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: 'var(--card-bg)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: '18px 21px',
|
|
||||||
backdropFilter: 'blur(12px)',
|
|
||||||
WebkitBackdropFilter: 'blur(12px)',
|
|
||||||
width: 'min(320px, 100%)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 14 }}>
|
|
||||||
{uploading ? 'Uploading' : downloadingSelection ? 'Downloading' : 'Loading'}
|
|
||||||
</div>
|
|
||||||
{(() => {
|
|
||||||
const pct = uploading ? uploadProgress : downloadingSelection ? downloadProgress : null;
|
|
||||||
const hasProgress = (uploading || downloadingSelection) && pct !== null;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div style={{ height: 4, borderRadius: 2, background: 'var(--border)', overflow: 'hidden', marginBottom: hasProgress ? 8 : 0 }}>
|
|
||||||
<div
|
|
||||||
className={hasProgress ? undefined : 'file-browser-progress-bar indeterminate'}
|
|
||||||
style={{ height: '100%', borderRadius: 2, background: 'var(--accent)', width: hasProgress ? `${pct}%` : undefined, transition: 'width 0.2s ease' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{hasProgress && (
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)', textAlign: 'right' }}>{pct}%</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{ctxMenu && (() => {
|
{ctxMenu && (() => {
|
||||||
const { x, y, entry, childPath } = ctxMenu;
|
const { x, y, entry, childPath } = ctxMenu;
|
||||||
const isDir = entry ? (entry.type === 'directory' || entry.isDir) : false;
|
const isDir = entry ? (entry.type === 'directory' || entry.isDir) : false;
|
||||||
@@ -1016,7 +1018,7 @@ export default function FileSharing() {
|
|||||||
const separatorStyle = { height: 1, background: 'var(--border)', margin: '4px 0' };
|
const separatorStyle = { height: 1, background: 'var(--border)', margin: '4px 0' };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div onClick={(event) => event.stopPropagation()} style={{ position: 'fixed', left: x, top: y, zIndex: 1000, background: 'rgba(20,20,20,0.96)', border: '1px solid var(--border)', borderRadius: 8, backdropFilter: 'blur(16px)', padding: '4px 0', minWidth: 160, boxShadow: '0 8px 24px rgba(0,0,0,0.4)' }}>
|
<div onClick={(event) => event.stopPropagation()} style={{ ...popupMenuStyle, position: 'fixed', left: x, top: y, zIndex: 1000, padding: '4px 0', minWidth: 160 }}>
|
||||||
{entry && isDir && (
|
{entry && isDir && (
|
||||||
<button
|
<button
|
||||||
style={itemStyle}
|
style={itemStyle}
|
||||||
@@ -1131,7 +1133,7 @@ export default function FileSharing() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isTeam && entry && (
|
{accessScope.canManage && entry && (
|
||||||
<>
|
<>
|
||||||
<div style={separatorStyle} />
|
<div style={separatorStyle} />
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import SortTh from '../components/SortTh';
|
|||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { useSortable } from '../hooks/useSortable';
|
import { useSortable } from '../hooks/useSortable';
|
||||||
|
import { popupOverlayStyle } from '../lib/popupStyles';
|
||||||
|
|
||||||
function smoothCurve(pts) {
|
function smoothCurve(pts) {
|
||||||
if (pts.length < 2) return '';
|
if (pts.length < 2) return '';
|
||||||
@@ -75,6 +76,7 @@ export default function ProfilePage() {
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
async function loadViewedProfile() {
|
async function loadViewedProfile() {
|
||||||
if (!profileId || profileId === currentUser?.id) {
|
if (!profileId || profileId === currentUser?.id) {
|
||||||
|
setLoadingProfile(false);
|
||||||
setViewedProfile(null);
|
setViewedProfile(null);
|
||||||
setViewedCompanies([]);
|
setViewedCompanies([]);
|
||||||
setPrimaryCompanyAddress('');
|
setPrimaryCompanyAddress('');
|
||||||
@@ -249,18 +251,30 @@ export default function ProfilePage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[isSelfView, currentUser?.id, currentUser?.name, currentUser?.email, currentUser?.role, currentUser?.website, currentUser?.linkedin, currentUser?.avatar_url, viewedProfile]
|
[isSelfView, currentUser?.id, currentUser?.name, currentUser?.email, currentUser?.role, currentUser?.website, currentUser?.linkedin, currentUser?.avatar_url, viewedProfile]
|
||||||
);
|
);
|
||||||
const companyNames = useMemo(() => {
|
const profileScope = useMemo(() => {
|
||||||
|
const names = new Set();
|
||||||
if (isSelfView) {
|
if (isSelfView) {
|
||||||
const names = new Set((currentUser?.companies || []).map((c) => c?.company?.name).filter(Boolean));
|
(currentUser?.companies || []).forEach((row) => {
|
||||||
|
const name = row?.company?.name;
|
||||||
|
if (name) names.add(name);
|
||||||
|
});
|
||||||
if (currentUser?.company?.name) names.add(currentUser.company.name);
|
if (currentUser?.company?.name) names.add(currentUser.company.name);
|
||||||
return [...names];
|
} else {
|
||||||
|
(viewedCompanies || []).forEach((name) => { if (name) names.add(name); });
|
||||||
}
|
}
|
||||||
return viewedCompanies;
|
|
||||||
}, [isSelfView, currentUser, viewedCompanies]);
|
const address = isSelfView
|
||||||
const companyAddress = useMemo(() => {
|
? (currentUser?.company?.address || currentUser?.companies?.[0]?.company?.address || '')
|
||||||
if (isSelfView) return currentUser?.company?.address || currentUser?.companies?.[0]?.company?.address || '';
|
: (primaryCompanyAddress || '');
|
||||||
return primaryCompanyAddress || '';
|
|
||||||
}, [isSelfView, currentUser, primaryCompanyAddress]);
|
return {
|
||||||
|
companyNames: [...names],
|
||||||
|
companyAddress: address,
|
||||||
|
};
|
||||||
|
}, [isSelfView, currentUser, viewedCompanies, primaryCompanyAddress]);
|
||||||
|
|
||||||
|
const companyNames = profileScope.companyNames;
|
||||||
|
const companyAddress = profileScope.companyAddress;
|
||||||
const companyDisplayName = useMemo(() => {
|
const companyDisplayName = useMemo(() => {
|
||||||
const role = profile?.role;
|
const role = profile?.role;
|
||||||
if (role === 'team' || role === 'external') return 'Fourge Branding';
|
if (role === 'team' || role === 'external') return 'Fourge Branding';
|
||||||
@@ -427,7 +441,7 @@ export default function ProfilePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No tasks in progress</div>
|
<div style={{ minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No tasks in progress</div>
|
||||||
) : (
|
) : (
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||||
<colgroup>
|
<colgroup>
|
||||||
@@ -468,7 +482,7 @@ export default function ProfilePage() {
|
|||||||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Recent Activity</span>
|
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Recent Activity</span>
|
||||||
</div>
|
</div>
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 14 }}>No recent activity</div>
|
<div style={{ minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No recent activity</div>
|
||||||
) : items.map((e, i) => (
|
) : items.map((e, i) => (
|
||||||
<div key={e.id} style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: i > 0 ? 10 : 0 }}>
|
<div key={e.id} style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: i > 0 ? 10 : 0 }}>
|
||||||
<ActionIcon actionKey={e.action} />
|
<ActionIcon actionKey={e.action} />
|
||||||
@@ -496,7 +510,24 @@ export default function ProfilePage() {
|
|||||||
{!loadingProfile && !profileError && (
|
{!loadingProfile && !profileError && (
|
||||||
<div className="profile-top-grid">
|
<div className="profile-top-grid">
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||||
<div style={{ ...dashCardStyle, display: 'flex', alignItems: 'flex-start', gap: 20, position: 'relative' }}>
|
<div style={{
|
||||||
|
...dashCardStyle,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 20,
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundImage: `
|
||||||
|
radial-gradient(circle at 14% 88%, color-mix(in srgb, var(--accent) 10%, transparent) 0 2px, transparent 2px 16px),
|
||||||
|
radial-gradient(circle at 22% 80%, color-mix(in srgb, var(--accent) 8%, transparent) 0 2px, transparent 2px 16px),
|
||||||
|
radial-gradient(circle at 30% 72%, color-mix(in srgb, var(--accent) 7%, transparent) 0 2px, transparent 2px 16px),
|
||||||
|
radial-gradient(circle at 38% 64%, color-mix(in srgb, var(--accent) 6%, transparent) 0 2px, transparent 2px 16px),
|
||||||
|
radial-gradient(circle at 46% 56%, color-mix(in srgb, var(--accent) 5%, transparent) 0 2px, transparent 2px 16px),
|
||||||
|
radial-gradient(circle at 54% 48%, color-mix(in srgb, var(--accent) 4%, transparent) 0 2px, transparent 2px 16px),
|
||||||
|
radial-gradient(circle at 62% 40%, color-mix(in srgb, var(--accent) 3%, transparent) 0 2px, transparent 2px 16px),
|
||||||
|
radial-gradient(circle at 70% 32%, color-mix(in srgb, var(--accent) 2%, transparent) 0 2px, transparent 2px 16px)
|
||||||
|
`,
|
||||||
|
}}>
|
||||||
<div style={{ position: 'absolute', top: 18, right: 21, bottom: 18, display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'space-between' }}>
|
<div style={{ position: 'absolute', top: 18, right: 21, bottom: 18, display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'space-between' }}>
|
||||||
{isSelfView && (
|
{isSelfView && (
|
||||||
<button
|
<button
|
||||||
@@ -524,7 +555,7 @@ export default function ProfilePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ width: 140, height: 140, flexShrink: 0, borderRadius: '50%', background: 'var(--card-bg-2)', border: '2px solid #111', outline: '2px solid var(--accent)', outlineOffset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 36, color: 'var(--text-primary)', fontWeight: 500, lineHeight: 1, overflow: 'hidden' }}>
|
<div style={{ width: 140, height: 140, flexShrink: 0, borderRadius: '50%', background: 'var(--card-bg-2)', border: '2px solid var(--avatar-inner-ring)', outline: '2px solid var(--accent)', outlineOffset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 36, color: 'var(--text-primary)', fontWeight: 500, lineHeight: 1, overflow: 'hidden' }}>
|
||||||
{profile?.avatar_url
|
{profile?.avatar_url
|
||||||
? <img src={profile.avatar_url} alt={profile.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
? <img src={profile.avatar_url} alt={profile.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
: (initials || '?')}
|
: (initials || '?')}
|
||||||
@@ -610,18 +641,7 @@ export default function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
{isSelfView && editOpen && (
|
{isSelfView && editOpen && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={popupOverlayStyle}
|
||||||
position: 'fixed',
|
|
||||||
inset: 0,
|
|
||||||
zIndex: 1200,
|
|
||||||
background: 'rgba(0,0,0,0.58)',
|
|
||||||
backdropFilter: 'blur(6px)',
|
|
||||||
WebkitBackdropFilter: 'blur(6px)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
padding: 24,
|
|
||||||
}}
|
|
||||||
onClick={() => { if (!savingProfile) setEditOpen(false); }}
|
onClick={() => { if (!savingProfile) setEditOpen(false); }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -636,7 +656,7 @@ export default function ProfilePage() {
|
|||||||
<div style={{ ...metaLabelStyle, marginBottom: 14 }}>Edit Profile</div>
|
<div style={{ ...metaLabelStyle, marginBottom: 14 }}>Edit Profile</div>
|
||||||
<form onSubmit={handleProfileSave}>
|
<form onSubmit={handleProfileSave}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 20 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 20 }}>
|
||||||
<div style={{ width: 72, height: 72, borderRadius: '50%', background: 'var(--card-bg-2)', border: '2px solid #111', outline: '2px solid var(--accent)', outlineOffset: 0, flexShrink: 0, overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 24, color: 'var(--text-primary)', fontWeight: 500 }}>
|
<div style={{ width: 72, height: 72, borderRadius: '50%', background: 'var(--card-bg-2)', border: '2px solid var(--avatar-inner-ring)', outline: '2px solid var(--accent)', outlineOffset: 0, flexShrink: 0, overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 24, color: 'var(--text-primary)', fontWeight: 500 }}>
|
||||||
{profile?.avatar_url
|
{profile?.avatar_url
|
||||||
? <img src={profile.avatar_url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
? <img src={profile.avatar_url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
: (initials || '?')}
|
: (initials || '?')}
|
||||||
@@ -670,12 +690,12 @@ export default function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
{editError && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>{editError}</div>}
|
{editError && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>{editError}</div>}
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 6 }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 6 }}>
|
||||||
|
<button type="submit" className="btn btn-outline" disabled={savingProfile} style={modalButtonStyle}>
|
||||||
|
{savingProfile ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
<button type="button" className="btn btn-outline" onClick={() => setEditOpen(false)} disabled={savingProfile} style={modalButtonStyle}>
|
<button type="button" className="btn btn-outline" onClick={() => setEditOpen(false)} disabled={savingProfile} style={modalButtonStyle}>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" className="btn btn-outline" disabled={savingProfile} style={modalButtonStyle}>
|
|
||||||
{savingProfile ? 'Saving...' : 'Save Changes'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,678 @@
|
|||||||
|
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import Layout from '../components/Layout';
|
||||||
|
import StatusBadge from '../components/StatusBadge';
|
||||||
|
import RequestForm from '../components/RequestForm';
|
||||||
|
import { supabase } from '../lib/supabase';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { readPageCache, writePageCache } from '../lib/pageCache';
|
||||||
|
import { withTimeout } from '../lib/withTimeout';
|
||||||
|
import { getCurrentVersionForTask, getDeadlineSourceSubmission } from '../lib/taskDeadlines';
|
||||||
|
import { fmtShortDate } from '../lib/dates';
|
||||||
|
import { createInitialSubmissionForRequest, createTaskForRequest, findOrCreateProject } from '../lib/requestSubmission';
|
||||||
|
import { sendEmail } from '../lib/email';
|
||||||
|
import { uploadFilesToRequestInfo, createTaskFolder } from '../lib/filebrowserFolders';
|
||||||
|
import SortTh from '../components/SortTh';
|
||||||
|
import { useSortable } from '../hooks/useSortable';
|
||||||
|
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
|
||||||
|
|
||||||
|
const TASK_STAT_ICONS = {
|
||||||
|
total: '<rect x="3" y="4" width="18" height="16" rx="2"/><line x1="7" y1="9" x2="17" y2="9"/><line x1="7" y1="13" x2="17" y2="13"/>',
|
||||||
|
todo: '<circle cx="12" cy="12" r="8"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/>',
|
||||||
|
progress: '<polyline points="4,14 10,8 14,12 20,6"/><line x1="20" y1="6" x2="20" y2="11"/><line x1="20" y1="6" x2="15" y2="6"/>',
|
||||||
|
hold: '<rect x="7" y="6" width="3" height="12" rx="1"/><rect x="14" y="6" width="3" height="12" rx="1"/>',
|
||||||
|
review: '<path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6z"/><circle cx="12" cy="12" r="2.5"/>',
|
||||||
|
done: '<polyline points="4,12 9,17 20,6"/>',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TASK_TABLE_TH_STYLE = { fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.6, color: 'var(--text-muted)', textAlign: 'left', padding: '0 0 12px 5px', border: 'none', background: 'transparent', verticalAlign: 'top' };
|
||||||
|
const TASK_TABLE_TD_BASE = { padding: '5px', border: 'none', background: 'transparent', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
|
||||||
|
const TASK_MODAL_TITLE_STYLE = { fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' };
|
||||||
|
const TASK_MODAL_LABEL_STYLE = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 6 };
|
||||||
|
const TASK_MODAL_INPUT_STYLE = { fontSize: 13, padding: '0 5px', textAlign: 'left', lineHeight: 1 };
|
||||||
|
const TASK_MODAL_BUTTON_STYLE = { lineHeight: 1, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' };
|
||||||
|
const TASK_MODAL_SHELL_STYLE = { ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(434px, 100%)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto' };
|
||||||
|
const TASK_STATUS_SORT_RANK = { not_started: 0, in_progress: 1, on_hold: 2, client_review: 3, client_approved: 4, invoiced: 5, paid: 6 };
|
||||||
|
|
||||||
|
function TaskStatCard({ label, value, iconBg, iconColor, iconPath }) {
|
||||||
|
return (
|
||||||
|
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '14px 21px', minHeight: 86 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{label}</div>
|
||||||
|
<div style={{ width: 27, height: 27, borderRadius: '50%', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={iconColor} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" dangerouslySetInnerHTML={{ __html: iconPath }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<div style={{ fontSize: 26, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{value}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TasksStatsRow({ tasks = [] }) {
|
||||||
|
const toDo = tasks.filter(t => t?.status === 'not_started').length;
|
||||||
|
const inProgress = tasks.filter(t => t?.status === 'in_progress').length;
|
||||||
|
const onHold = tasks.filter(t => t?.status === 'on_hold').length;
|
||||||
|
const inReview = tasks.filter(t => t?.status === 'client_review').length;
|
||||||
|
const completed = tasks.filter(t => ['client_approved', 'invoiced', 'paid'].includes(t?.status)).length;
|
||||||
|
return (
|
||||||
|
<div className="dash-stat-grid" style={{ gridTemplateColumns: '1fr 1fr 1fr 1fr 1fr 1fr' }}>
|
||||||
|
<TaskStatCard label="To Do" value={toDo} iconBg="rgba(167,139,250,0.15)" iconColor="#a78bfa" iconPath={TASK_STAT_ICONS.todo} />
|
||||||
|
<TaskStatCard label="In Progress" value={inProgress} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconPath={TASK_STAT_ICONS.progress} />
|
||||||
|
<TaskStatCard label="On Hold" value={onHold} iconBg="rgba(239,68,68,0.15)" iconColor="#ef4444" iconPath={TASK_STAT_ICONS.hold} />
|
||||||
|
<TaskStatCard label="In Review" value={inReview} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={TASK_STAT_ICONS.review} />
|
||||||
|
<TaskStatCard label="Completed" value={completed} iconBg="rgba(74,222,128,0.15)" iconColor="#4ade80" iconPath={TASK_STAT_ICONS.done} />
|
||||||
|
<TaskStatCard label="Total Tasks" value={tasks.length} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={TASK_STAT_ICONS.total} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TasksPageShell({ statsTasks = [], projects = [], onAddProject = null, children }) {
|
||||||
|
const [projSortKey, setProjSortKey] = useState('status');
|
||||||
|
const [projSortDir, setProjSortDir] = useState('asc');
|
||||||
|
const [projTab, setProjTab] = useState('all');
|
||||||
|
const toggleProjSort = (col) => {
|
||||||
|
if (projSortKey === col) setProjSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
||||||
|
else { setProjSortKey(col); setProjSortDir('asc'); }
|
||||||
|
};
|
||||||
|
const completedStatuses = new Set(['completed', 'cancelled', 'archived', 'done']);
|
||||||
|
const projectTabs = [
|
||||||
|
{ id: 'all', label: 'All Projects' },
|
||||||
|
{ id: 'active', label: 'Active' },
|
||||||
|
{ id: 'completed', label: 'Completed' },
|
||||||
|
];
|
||||||
|
const visibleProjects = projects.filter(p => {
|
||||||
|
if (projTab === 'all') return true;
|
||||||
|
const done = completedStatuses.has((p?.status || '').toLowerCase());
|
||||||
|
return projTab === 'completed' ? done : !done;
|
||||||
|
});
|
||||||
|
const sortedProjects = [...visibleProjects].sort((a, b) => {
|
||||||
|
if (projSortKey === 'status') {
|
||||||
|
const ra = completedStatuses.has((a.status || '').toLowerCase()) ? 1 : 0;
|
||||||
|
const rb = completedStatuses.has((b.status || '').toLowerCase()) ? 1 : 0;
|
||||||
|
if (ra !== rb) return projSortDir === 'asc' ? ra - rb : rb - ra;
|
||||||
|
}
|
||||||
|
const av = (a.name || '').toLowerCase();
|
||||||
|
const bv = (b.name || '').toLowerCase();
|
||||||
|
return projSortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
|
||||||
|
});
|
||||||
|
const projectCompletionPct = (project) => {
|
||||||
|
const pt = statsTasks.filter(t => t?.project_id === project?.id);
|
||||||
|
if (pt.length > 0) {
|
||||||
|
return Math.round((pt.filter(t => ['client_approved', 'invoiced', 'paid'].includes(t?.status)).length / pt.length) * 100);
|
||||||
|
}
|
||||||
|
const s = (project?.status || '').toLowerCase();
|
||||||
|
if (s === 'completed' || s === 'done') return 100;
|
||||||
|
if (s === 'cancelled' || s === 'archived') return 0;
|
||||||
|
return 35;
|
||||||
|
};
|
||||||
|
const tabBtnStyle = (active) => ({
|
||||||
|
fontFamily: "Fourge, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||||
|
fontSize: 13, fontWeight: 500, letterSpacing: 0.2, padding: '2px 0 5px', margin: '0 8px',
|
||||||
|
borderRadius: 0, border: 'none', borderBottom: active ? '2px solid var(--accent)' : '2px solid transparent',
|
||||||
|
cursor: 'pointer', background: 'transparent',
|
||||||
|
color: active ? 'var(--text-primary)' : 'var(--text-secondary)', transition: 'all 160ms',
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div style={{ minWidth: 0, flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||||
|
<TasksStatsRow tasks={statsTasks} />
|
||||||
|
<div style={{ display: 'flex', gap: 24, flex: 1, minHeight: 0, alignItems: 'stretch' }}>
|
||||||
|
<div style={{ flex: '0 0 70%', minWidth: 0, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: '0 0 calc(30% - 24px)', minWidth: 0, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10, flexShrink: 0 }}>
|
||||||
|
{projectTabs.map(t => (
|
||||||
|
<button key={t.id} onClick={() => setProjTab(t.id)} style={tabBtnStyle(projTab === t.id)}>{t.label}</button>
|
||||||
|
))}
|
||||||
|
{onAddProject && (
|
||||||
|
<div style={{ marginLeft: 'auto' }}>
|
||||||
|
<button className="btn btn-outline" onClick={onAddProject}>+ Project</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', height: '100%', boxSizing: 'border-box', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||||
|
<div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||||
|
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||||
|
<colgroup>
|
||||||
|
<col style={{ width: '60%' }} />
|
||||||
|
<col style={{ width: '40%' }} />
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<SortTh col="name" sortKey={projSortKey} sortDir={projSortDir} onSort={toggleProjSort} style={TASK_TABLE_TH_STYLE}>Name</SortTh>
|
||||||
|
<SortTh col="status" sortKey={projSortKey} sortDir={projSortDir} onSort={toggleProjSort} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Status</SortTh>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sortedProjects.length === 0 ? (
|
||||||
|
<tr><td colSpan={2} style={{ fontSize: 13, color: 'var(--text-muted)', padding: '28px 5px', border: 'none', background: 'transparent', textAlign: 'center' }}>No projects.</td></tr>
|
||||||
|
) : sortedProjects.map(p => (
|
||||||
|
<tr key={p.id}>
|
||||||
|
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)', textAlign: 'left' }}>
|
||||||
|
<Link to={`/projects/${p.id}`} className="table-link">{p.name}</Link>
|
||||||
|
</td>
|
||||||
|
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-muted)', textAlign: 'center' }}>
|
||||||
|
<Link to={`/projects/${p.id}`} className="table-link" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6, width: '100%' }}>
|
||||||
|
<span style={{ width: 68, height: 4, borderRadius: 2, background: 'var(--border)', overflow: 'hidden', position: 'relative', flexShrink: 0 }}>
|
||||||
|
<span style={{ position: 'absolute', inset: 0, width: `${projectCompletionPct(p)}%`, background: 'var(--accent)', borderRadius: 2 }} />
|
||||||
|
</span>
|
||||||
|
<span style={{ minWidth: 28, textAlign: 'right', fontSize: 12, color: 'var(--text-secondary)' }}>{projectCompletionPct(p)}%</span>
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RequestsPage() {
|
||||||
|
const { currentUser } = useAuth();
|
||||||
|
const isTeam = currentUser?.role === 'team';
|
||||||
|
const isExternal = currentUser?.role === 'external';
|
||||||
|
const isClient = currentUser?.role === 'client';
|
||||||
|
|
||||||
|
const teamCached = isTeam ? readPageCache('team_requests') : null;
|
||||||
|
const extCached = isExternal ? readPageCache(`ext-requests:${currentUser?.id}`, 3 * 60_000) : null;
|
||||||
|
|
||||||
|
const [projects, setProjects] = useState(() => teamCached?.projects || extCached?.projects || []);
|
||||||
|
const [tasks, setTasks] = useState(() => teamCached?.tasks || extCached?.tasks || []);
|
||||||
|
const [submissions, setSubmissions] = useState(() => teamCached?.submissions || extCached?.submissions || []);
|
||||||
|
const [companies, setCompanies] = useState(() => teamCached?.companies || []);
|
||||||
|
const [loading, setLoading] = useState(() => {
|
||||||
|
if (isTeam) return !teamCached;
|
||||||
|
if (isExternal) return !extCached;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loadError, setLoadError] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState('all');
|
||||||
|
const [filterCompany, setFilterCompany] = useState('');
|
||||||
|
const [filterProject, setFilterProject] = useState('');
|
||||||
|
const { sortKey, sortDir, toggle, sort } = useSortable('status', 'asc');
|
||||||
|
|
||||||
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
|
const [addFormKey, setAddFormKey] = useState(0);
|
||||||
|
const [addSaving, setAddSaving] = useState(false);
|
||||||
|
const [addError, setAddError] = useState('');
|
||||||
|
const [addRequestKey, setAddRequestKey] = useState(() => crypto.randomUUID());
|
||||||
|
const [showAddProjectForm, setShowAddProjectForm] = useState(false);
|
||||||
|
const [addProjectSaving, setAddProjectSaving] = useState(false);
|
||||||
|
const [addProjectError, setAddProjectError] = useState('');
|
||||||
|
const [addProjectForm, setAddProjectForm] = useState({ name: '', companyId: '' });
|
||||||
|
const [companyFilterMenuOpen, setCompanyFilterMenuOpen] = useState(false);
|
||||||
|
const companyFilterMenuRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!companyFilterMenuOpen) return;
|
||||||
|
function onDocClick(e) {
|
||||||
|
if (companyFilterMenuRef.current && !companyFilterMenuRef.current.contains(e.target)) setCompanyFilterMenuOpen(false);
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onDocClick);
|
||||||
|
return () => document.removeEventListener('mousedown', onDocClick);
|
||||||
|
}, [companyFilterMenuOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
async function loadTasksPage() {
|
||||||
|
if (!currentUser?.id) { setLoading(false); return; }
|
||||||
|
try {
|
||||||
|
setError(''); setLoadError(false);
|
||||||
|
|
||||||
|
let scopedProjectIds = null;
|
||||||
|
let scopedTaskIds = null;
|
||||||
|
|
||||||
|
if (isClient) {
|
||||||
|
const clientCos = currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : []);
|
||||||
|
const companyIds = clientCos.map(c => c.id).filter(Boolean);
|
||||||
|
if (companyIds.length > 0) {
|
||||||
|
const { data: projRows } = await withTimeout(
|
||||||
|
supabase.from('projects').select('id, tasks(id)').in('company_id', companyIds), 10000, 'Client project scope'
|
||||||
|
);
|
||||||
|
scopedProjectIds = (projRows || []).map(p => p.id);
|
||||||
|
scopedTaskIds = (projRows || []).flatMap(p => (p.tasks || []).map(t => t.id));
|
||||||
|
} else {
|
||||||
|
scopedProjectIds = []; scopedTaskIds = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isExternal) {
|
||||||
|
const { data: memberData } = await withTimeout(
|
||||||
|
supabase.from('project_members').select('project_id').eq('profile_id', currentUser.id), 10000, 'External project scope'
|
||||||
|
);
|
||||||
|
scopedProjectIds = (memberData || []).map(r => r.project_id).filter(Boolean);
|
||||||
|
if (scopedProjectIds.length > 0) {
|
||||||
|
const { data: taskRows } = await withTimeout(
|
||||||
|
supabase.from('tasks').select('id').in('project_id', scopedProjectIds), 10000, 'External task scope'
|
||||||
|
);
|
||||||
|
scopedTaskIds = (taskRows || []).map(r => r.id).filter(Boolean);
|
||||||
|
} else {
|
||||||
|
scopedTaskIds = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasksQ = supabase.from('tasks')
|
||||||
|
.select('id, title, status, current_version, project_id, assigned_name, assigned_to, invoiced, completed_at, submitted_at')
|
||||||
|
.order('submitted_at', { ascending: false });
|
||||||
|
if (!isTeam) {
|
||||||
|
if ((scopedProjectIds || []).length > 0) tasksQ.in('project_id', scopedProjectIds);
|
||||||
|
else tasksQ.eq('id', '__none__');
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectsQ = supabase.from('projects').select('id, name, status, company_id, company:companies(id, name)');
|
||||||
|
if (!isTeam) {
|
||||||
|
if ((scopedProjectIds || []).length > 0) projectsQ.in('id', scopedProjectIds);
|
||||||
|
else projectsQ.eq('id', '__none__');
|
||||||
|
}
|
||||||
|
|
||||||
|
const subsQ = supabase.from('submissions')
|
||||||
|
.select('id, task_id, submitted_at, submitted_by, submitted_by_name, is_hot, service_type, deadline, version_number, type')
|
||||||
|
.order('submitted_at', { ascending: false });
|
||||||
|
if (!isTeam) {
|
||||||
|
if ((scopedTaskIds || []).length > 0) subsQ.in('task_id', scopedTaskIds);
|
||||||
|
else subsQ.eq('id', '__none__');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [{ data: subs }, { data: t }, { data: p }, { data: co }] = await withTimeout(
|
||||||
|
Promise.all([subsQ, tasksQ, projectsQ, supabase.from('companies').select('id, name')]),
|
||||||
|
15000, 'Tasks page load'
|
||||||
|
);
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
setProjects(p || []); setTasks(t || []); setSubmissions(subs || []); setCompanies(co || []);
|
||||||
|
|
||||||
|
if (isTeam) {
|
||||||
|
writePageCache('team_requests', { submissions: subs || [], tasks: t || [], projects: p || [], companies: co || [] });
|
||||||
|
} else if (isExternal) {
|
||||||
|
writePageCache(`ext-requests:${currentUser.id}`, { projects: p || [], tasks: t || [], submissions: subs || [] });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Tasks page load failed:', err);
|
||||||
|
setError(err.message || 'Failed to load tasks.');
|
||||||
|
setLoadError(true);
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadTasksPage();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [isTeam, isExternal, isClient, currentUser?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const handleAddRequest = async (formData, _files, existingProjects) => {
|
||||||
|
if (addSaving) return;
|
||||||
|
setAddSaving(true); setAddError('');
|
||||||
|
try {
|
||||||
|
const resolvedProject = await findOrCreateProject(formData.companyId, formData.project.trim(), existingProjects);
|
||||||
|
if (!projects.some(p => p.id === resolvedProject.id)) setProjects(prev => [...prev, resolvedProject]);
|
||||||
|
const { task } = await createTaskForRequest({ projectId: resolvedProject.id, title: formData.title.trim(), requestKey: addRequestKey });
|
||||||
|
if (!task) throw new Error('Failed to create task.');
|
||||||
|
const taskCompany = companies?.find(c => c.id === formData.companyId);
|
||||||
|
createTaskFolder(taskCompany?.name, resolvedProject.name, formData.title.trim()).catch(() => {});
|
||||||
|
const { submission: sub } = await createInitialSubmissionForRequest({
|
||||||
|
taskId: task.id, requestKey: addRequestKey, isHot: formData.isHot,
|
||||||
|
serviceType: formData.serviceType, deadline: formData.deadline,
|
||||||
|
description: formData.description, submittedBy: formData.requestedBy, submittedByName: formData.requestedByName,
|
||||||
|
});
|
||||||
|
if (!sub) throw new Error('Failed to create submission.');
|
||||||
|
const [{ data: newSubs }, { data: newTasks }] = await Promise.all([
|
||||||
|
supabase.from('submissions').select('id, task_id, submitted_at, submitted_by, submitted_by_name, is_hot, service_type, deadline, version_number, type').order('submitted_at', { ascending: false }),
|
||||||
|
supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_name, assigned_to, invoiced, completed_at, submitted_at').order('submitted_at', { ascending: false }),
|
||||||
|
]);
|
||||||
|
setSubmissions(newSubs || []); setTasks(newTasks || []);
|
||||||
|
setShowAddForm(false); setAddFormKey(k => k + 1); setAddRequestKey(crypto.randomUUID());
|
||||||
|
} catch (err) { setAddError(err.message); }
|
||||||
|
finally { setAddSaving(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClientRequest = async (formData, files, existingProjects) => {
|
||||||
|
if (addSaving) return;
|
||||||
|
setAddSaving(true); setAddError('');
|
||||||
|
try {
|
||||||
|
const clientCos = currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : []);
|
||||||
|
const selectedCompany = clientCos.find(c => c.id === formData.companyId);
|
||||||
|
const resolvedProject = await findOrCreateProject(formData.companyId, formData.project.trim(), existingProjects);
|
||||||
|
const { task } = await createTaskForRequest({ projectId: resolvedProject.id, title: formData.title.trim(), requestKey: addRequestKey });
|
||||||
|
if (!task) throw new Error('Failed to create task.');
|
||||||
|
createTaskFolder(selectedCompany?.name, resolvedProject.name, formData.title.trim()).catch(() => {});
|
||||||
|
const { submission } = await createInitialSubmissionForRequest({
|
||||||
|
taskId: task.id, requestKey: addRequestKey, isHot: formData.isHot,
|
||||||
|
serviceType: formData.serviceType, deadline: formData.deadline,
|
||||||
|
description: formData.description, submittedBy: currentUser.id, submittedByName: currentUser.name,
|
||||||
|
});
|
||||||
|
if (submission && files.length > 0) {
|
||||||
|
for (const file of files) {
|
||||||
|
const path = `${task.id}/${Date.now()}_${file.name}`;
|
||||||
|
const { data: uploaded, error: uploadError } = await supabase.storage.from('submissions').upload(path, file);
|
||||||
|
if (uploadError) { await supabase.from('tasks').delete().eq('id', task.id); throw new Error(`Upload failed: ${uploadError.message}`); }
|
||||||
|
if (uploaded) {
|
||||||
|
const { error: fileErr } = await supabase.from('submission_files').insert({ submission_id: submission.id, name: file.name, storage_path: path, size: file.size });
|
||||||
|
if (fileErr) { await supabase.from('tasks').delete().eq('id', task.id); throw new Error(`File record failed: ${fileErr.message}`); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
uploadFilesToRequestInfo(files, selectedCompany?.name, resolvedProject.name, formData.title.trim()).catch(() => {});
|
||||||
|
}
|
||||||
|
sendEmail('new_request', 'hello@fourgebranding.com', {
|
||||||
|
clientName: currentUser.name, clientEmail: currentUser.email,
|
||||||
|
company: selectedCompany?.name || '', serviceType: formData.serviceType,
|
||||||
|
projectName: formData.project, deadline: formData.deadline,
|
||||||
|
description: formData.description, taskId: task.id,
|
||||||
|
}).catch(() => {});
|
||||||
|
const refreshCompanyIds = clientCos.map(c => c.id).filter(Boolean);
|
||||||
|
const { data: refreshProjects } = await supabase.from('projects').select('id, name, status, company_id, company:companies(id, name)').in('company_id', refreshCompanyIds);
|
||||||
|
const refreshProjectIds = (refreshProjects || []).map(p => p.id);
|
||||||
|
const { data: newTasks } = await supabase.from('tasks')
|
||||||
|
.select('id, title, status, current_version, project_id, assigned_name, assigned_to, invoiced, completed_at, submitted_at')
|
||||||
|
.in('project_id', refreshProjectIds.length > 0 ? refreshProjectIds : ['__none__'])
|
||||||
|
.order('submitted_at', { ascending: false });
|
||||||
|
const refreshTaskIds = (newTasks || []).map(t => t.id);
|
||||||
|
const { data: newSubs } = await supabase.from('submissions')
|
||||||
|
.select('id, task_id, submitted_at, submitted_by, submitted_by_name, is_hot, service_type, deadline, version_number, type')
|
||||||
|
.in('task_id', refreshTaskIds.length > 0 ? refreshTaskIds : ['__none__'])
|
||||||
|
.order('submitted_at', { ascending: false });
|
||||||
|
setProjects(refreshProjects || []); setTasks(newTasks || []); setSubmissions(newSubs || []);
|
||||||
|
setShowAddForm(false); setAddFormKey(k => k + 1); setAddRequestKey(crypto.randomUUID());
|
||||||
|
} catch (err) { setAddError(err.message); }
|
||||||
|
finally { setAddSaving(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddProject = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (addProjectSaving) return;
|
||||||
|
setAddProjectSaving(true); setAddProjectError('');
|
||||||
|
try {
|
||||||
|
const name = addProjectForm.name.trim();
|
||||||
|
if (!name) throw new Error('Project name required.');
|
||||||
|
if (!addProjectForm.companyId) throw new Error('Company required.');
|
||||||
|
const { data: newProject, error: insertError } = await supabase
|
||||||
|
.from('projects').insert({ name, company_id: addProjectForm.companyId, status: 'active' })
|
||||||
|
.select('id, name, status, company_id, company:companies(id, name)').single();
|
||||||
|
if (insertError) throw insertError;
|
||||||
|
setProjects(prev => [...prev, newProject]);
|
||||||
|
setShowAddProjectForm(false); setAddProjectForm({ name: '', companyId: '' });
|
||||||
|
} catch (err) { setAddProjectError(err.message || 'Failed to create project.'); }
|
||||||
|
finally { setAddProjectSaving(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Companies available for filter menu and forms
|
||||||
|
const filterableCompanies = useMemo(() => {
|
||||||
|
if (isClient) {
|
||||||
|
const cos = currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : []);
|
||||||
|
return cos.slice().sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
return companies.slice().sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||||
|
}, [isClient, companies, currentUser]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// Normalize all tasks to a common row shape for unified table render
|
||||||
|
const allRows = useMemo(() => {
|
||||||
|
if (isClient) {
|
||||||
|
return tasks.map(task => {
|
||||||
|
const sub = submissions.find(s => s.task_id === task.id && s.type === 'initial');
|
||||||
|
return {
|
||||||
|
id: task.id, title: task.title, status: task.status, projectId: task.project_id,
|
||||||
|
serviceType: sub?.service_type || '—', deadline: sub?.deadline || null,
|
||||||
|
version: task.current_version || 0, isHot: false,
|
||||||
|
submittedAt: task.submitted_at ? new Date(task.submitted_at).getTime() : 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return tasks.map(task => {
|
||||||
|
const taskSubs = submissions.filter(s => s.task_id === task.id);
|
||||||
|
const deadlineSource = getDeadlineSourceSubmission(task, taskSubs);
|
||||||
|
if (!deadlineSource) return null;
|
||||||
|
const currentVersion = getCurrentVersionForTask(task, taskSubs);
|
||||||
|
const latestGroup = taskSubs.filter(s => s.version_number === currentVersion);
|
||||||
|
const serviceType = submissions.find(s => s.task_id === task.id && s.type === 'initial')?.service_type
|
||||||
|
|| submissions.find(s => s.task_id === task.id && s.service_type)?.service_type
|
||||||
|
|| deadlineSource.service_type || '—';
|
||||||
|
return {
|
||||||
|
id: task.id, title: task.title, status: task.status, projectId: task.project_id,
|
||||||
|
serviceType, deadline: deadlineSource.deadline,
|
||||||
|
version: deadlineSource.version_number ?? 0,
|
||||||
|
isHot: deadlineSource.is_hot || false,
|
||||||
|
submittedAt: latestGroup.length > 0
|
||||||
|
? Math.max(...latestGroup.map(s => new Date(s.submitted_at).getTime()))
|
||||||
|
: (task.submitted_at ? new Date(task.submitted_at).getTime() : 0),
|
||||||
|
};
|
||||||
|
}).filter(Boolean);
|
||||||
|
}, [tasks, submissions, isClient]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const filteredRows = useMemo(() => {
|
||||||
|
return allRows
|
||||||
|
.filter(row => {
|
||||||
|
const project = projects.find(p => p.id === row.projectId);
|
||||||
|
if (filterCompany && project?.company_id !== filterCompany) return false;
|
||||||
|
if (filterProject && row.projectId !== filterProject) return false;
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.submittedAt - a.submittedAt);
|
||||||
|
}, [allRows, projects, filterCompany, filterProject]);
|
||||||
|
|
||||||
|
const doneStatuses = new Set(['client_approved', 'invoiced', 'paid']);
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'all', label: 'All Tasks' },
|
||||||
|
{ id: 'not_started', label: 'To Do' },
|
||||||
|
{ id: 'in_progress', label: 'In Progress' },
|
||||||
|
{ id: 'on_hold', label: 'On Hold' },
|
||||||
|
{ id: 'client_review', label: 'In Review' },
|
||||||
|
{ id: 'completed', label: 'Completed' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const tabRows = activeTab === 'all' ? filteredRows
|
||||||
|
: activeTab === 'completed' ? filteredRows.filter(r => doneStatuses.has(r.status))
|
||||||
|
: filteredRows.filter(r => r.status === activeTab);
|
||||||
|
|
||||||
|
const sortedRows = sort(tabRows, (row, key) => {
|
||||||
|
if (key === 'title') return row.title || '';
|
||||||
|
if (key === 'project') return projects.find(p => p.id === row.projectId)?.name || '';
|
||||||
|
if (key === 'serviceType') return row.serviceType || '';
|
||||||
|
if (key === 'revision') return row.version ?? 0;
|
||||||
|
if (key === 'deadline') return row.deadline || '';
|
||||||
|
if (key === 'status') return TASK_STATUS_SORT_RANK[row.status] ?? 99;
|
||||||
|
if (key === 'submitted_at') return row.submittedAt || 0;
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectOptions = useMemo(() => {
|
||||||
|
if (!isExternal) return [];
|
||||||
|
return [...new Map(
|
||||||
|
allRows.map(r => projects.find(p => p.id === r.projectId)).filter(Boolean).map(p => [p.id, p])
|
||||||
|
).values()];
|
||||||
|
}, [isExternal, allRows, projects]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const tabBtnStyle = (active) => ({
|
||||||
|
fontFamily: "Fourge, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||||
|
fontSize: 13, fontWeight: 500, letterSpacing: 0.2, padding: '2px 0 5px', margin: '0 8px',
|
||||||
|
borderRadius: 0, border: 'none', borderBottom: active ? '2px solid var(--accent)' : '2px solid transparent',
|
||||||
|
cursor: 'pointer', background: 'transparent',
|
||||||
|
color: active ? 'var(--text-primary)' : 'var(--text-secondary)', transition: 'all 160ms',
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderRow = (row) => {
|
||||||
|
const project = projects.find(p => p.id === row.projectId);
|
||||||
|
return (
|
||||||
|
<tr key={row.id}>
|
||||||
|
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<Link to={`/requests/${row.id}`} className="table-link">{row.title || '—'}</Link>
|
||||||
|
<span style={{ color: 'var(--text-muted)' }}>{`R${String(row.version).padStart(2, '0')}`}</span>
|
||||||
|
{row.isHot && <span className="badge badge-needs_revision">HOT</span>}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)' }}>
|
||||||
|
{project ? <Link to={`/projects/${project.id}`} className="table-link">{project.name}</Link> : '—'}
|
||||||
|
</td>
|
||||||
|
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>
|
||||||
|
<Link to={`/requests/${row.id}`} className="table-link">{row.serviceType}</Link>
|
||||||
|
</td>
|
||||||
|
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>
|
||||||
|
<Link to={`/requests/${row.id}`} className="table-link">{fmtShortDate(row.deadline, 'Not specified')}</Link>
|
||||||
|
</td>
|
||||||
|
<td style={{ ...TASK_TABLE_TD_BASE, textAlign: 'center' }}>
|
||||||
|
<Link to={`/requests/${row.id}`} className="table-link"><StatusBadge status={row.status || 'not_started'} /></Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||||
|
if (loadError) return <Layout><div style={{ padding: 24 }}><p style={{ color: 'var(--text-muted)', marginBottom: 12 }}>Failed to load. Check your connection and try again.</p><button className="btn btn-outline" onClick={() => window.location.reload()}>Retry</button></div></Layout>;
|
||||||
|
|
||||||
|
const filteredStatTasks = filteredRows.map(r => tasks.find(t => t.id === r.id)).filter(Boolean);
|
||||||
|
const filteredProjectsShell = filterCompany ? projects.filter(p => p.company_id === filterCompany) : projects;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<TasksPageShell
|
||||||
|
statsTasks={filteredStatTasks}
|
||||||
|
projects={filteredProjectsShell}
|
||||||
|
onAddProject={(isTeam || isClient) ? () => {
|
||||||
|
setAddProjectForm(f => ({ ...f, companyId: !isTeam && filterableCompanies.length === 1 ? filterableCompanies[0].id : f.companyId }));
|
||||||
|
setShowAddProjectForm(true); setAddProjectError('');
|
||||||
|
} : null}
|
||||||
|
>
|
||||||
|
{/* Add project modal */}
|
||||||
|
{showAddProjectForm && (
|
||||||
|
<div style={popupOverlayStyle} onClick={() => { setShowAddProjectForm(false); setAddProjectForm({ name: '', companyId: '' }); setAddProjectError(''); }}>
|
||||||
|
<div style={TASK_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 14 }}>
|
||||||
|
<div style={TASK_MODAL_TITLE_STYLE}>Add Project</div>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleAddProject}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label style={TASK_MODAL_LABEL_STYLE}>Company *</label>
|
||||||
|
<select value={addProjectForm.companyId} onChange={e => setAddProjectForm(f => ({ ...f, companyId: e.target.value }))} required style={TASK_MODAL_INPUT_STYLE}>
|
||||||
|
<option value="">Select company...</option>
|
||||||
|
{filterableCompanies.map(co => <option key={co.id} value={co.id}>{co.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label style={TASK_MODAL_LABEL_STYLE}>Project Name *</label>
|
||||||
|
<input type="text" placeholder="e.g. Brand Identity 2026" value={addProjectForm.name} onChange={e => setAddProjectForm(f => ({ ...f, name: e.target.value }))} required autoFocus style={TASK_MODAL_INPUT_STYLE} />
|
||||||
|
</div>
|
||||||
|
{addProjectError && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>{addProjectError}</div>}
|
||||||
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 20 }}>
|
||||||
|
<button type="submit" className="btn btn-outline" style={TASK_MODAL_BUTTON_STYLE} disabled={addProjectSaving}>{addProjectSaving ? 'Saving...' : 'Save'}</button>
|
||||||
|
<button type="button" className="btn btn-outline" style={TASK_MODAL_BUTTON_STYLE} onClick={() => { setShowAddProjectForm(false); setAddProjectForm({ name: '', companyId: '' }); setAddProjectError(''); }}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add task modal */}
|
||||||
|
{showAddForm && (
|
||||||
|
<div style={popupOverlayStyle} onClick={() => { setShowAddForm(false); setAddFormKey(k => k + 1); setAddError(''); }}>
|
||||||
|
<div style={TASK_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 14 }}>
|
||||||
|
<div style={TASK_MODAL_TITLE_STYLE}>{isTeam ? 'Add Task' : 'New Task'}</div>
|
||||||
|
</div>
|
||||||
|
<RequestForm
|
||||||
|
key={addFormKey}
|
||||||
|
companies={isTeam ? companies : filterableCompanies}
|
||||||
|
initialCompanyId={!isTeam && filterableCompanies.length === 1 ? filterableCompanies[0].id : ''}
|
||||||
|
currentUser={currentUser}
|
||||||
|
showRequester={isTeam}
|
||||||
|
onSubmit={isTeam ? handleAddRequest : handleClientRequest}
|
||||||
|
onCancel={() => { setShowAddForm(false); setAddFormKey(k => k + 1); setAddError(''); }}
|
||||||
|
saving={addSaving}
|
||||||
|
error={addError}
|
||||||
|
submitLabel="Save"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Controls bar: tabs left, filters + actions right */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10, flexShrink: 0 }}>
|
||||||
|
{tabs.map(t => (
|
||||||
|
<button key={t.id} onClick={() => setActiveTab(t.id)} style={tabBtnStyle(activeTab === t.id)}>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }} ref={companyFilterMenuRef}>
|
||||||
|
{isExternal && projectOptions.length > 0 && (
|
||||||
|
<select className="filter-select" style={{ width: 120 }} value={filterProject} onChange={e => setFilterProject(e.target.value)}>
|
||||||
|
<option value="">All Projects</option>
|
||||||
|
{projectOptions.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
{(isTeam || isClient) && (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline"
|
||||||
|
aria-label="Filter companies" title="Filter companies"
|
||||||
|
onClick={() => setCompanyFilterMenuOpen(o => !o)}
|
||||||
|
style={{ width: 30, minWidth: 30, padding: 0, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M2 4h12M4 8h8M6 12h4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{companyFilterMenuOpen && (
|
||||||
|
<div className="site-header-avatar-menu" style={{ position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 220, minWidth: 170 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => { setFilterCompany(''); setFilterProject(''); setCompanyFilterMenuOpen(false); }}
|
||||||
|
className="site-header-avatar-item"
|
||||||
|
style={{ background: !filterCompany ? 'rgba(245,165,35,0.08)' : 'transparent', color: !filterCompany ? 'var(--accent)' : 'var(--text-primary)' }}
|
||||||
|
>All Companies</button>
|
||||||
|
{filterableCompanies.map(co => (
|
||||||
|
<button
|
||||||
|
key={co.id}
|
||||||
|
onClick={() => { setFilterCompany(co.id); setFilterProject(''); setCompanyFilterMenuOpen(false); }}
|
||||||
|
className="site-header-avatar-item"
|
||||||
|
style={{ background: filterCompany === co.id ? 'rgba(245,165,35,0.08)' : 'transparent', color: filterCompany === co.id ? 'var(--accent)' : 'var(--text-primary)' }}
|
||||||
|
>{co.name}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(isTeam || isClient) && (
|
||||||
|
<button className="btn btn-outline" onClick={() => { setShowAddForm(s => !s); setAddError(''); }}>+ Task</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Task table */}
|
||||||
|
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', background: 'var(--card-bg)', borderRadius: 8, border: '1px solid var(--border)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', padding: '18px 21px' }}>
|
||||||
|
{allRows.length === 0 ? (
|
||||||
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No tasks yet.</div>
|
||||||
|
) : sortedRows.length === 0 ? (
|
||||||
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No tasks.</div>
|
||||||
|
) : (
|
||||||
|
<div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||||
|
<table className="table-sticky-head" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
|
||||||
|
<colgroup>
|
||||||
|
<col style={{ width: '28%' }} />
|
||||||
|
<col style={{ width: '25%' }} />
|
||||||
|
<col style={{ width: '15%' }} />
|
||||||
|
<col style={{ width: '12%' }} />
|
||||||
|
<col style={{ width: '20%' }} />
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<SortTh col="title" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TASK_TABLE_TH_STYLE}>Name</SortTh>
|
||||||
|
<SortTh col="project" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TASK_TABLE_TH_STYLE}>Project</SortTh>
|
||||||
|
<SortTh col="serviceType" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Task Type</SortTh>
|
||||||
|
<SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Deadline</SortTh>
|
||||||
|
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Status</SortTh>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>{sortedRows.map(renderRow)}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{error && <div style={{ color: 'var(--danger)', marginTop: 16, fontSize: 13 }}>{error}</div>}
|
||||||
|
</TasksPageShell>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -165,7 +165,7 @@ function ActivityFeed({ events }) {
|
|||||||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Recent Activity</span>
|
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Recent Activity</span>
|
||||||
</div>
|
</div>
|
||||||
{visible.length === 0 ? (
|
{visible.length === 0 ? (
|
||||||
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 14 }}>No recent activity</div>
|
<div style={{ minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No recent activity</div>
|
||||||
) : visible.map((e, i) => (
|
) : visible.map((e, i) => (
|
||||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: i > 0 ? 10 : 0 }}>
|
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: i > 0 ? 10 : 0 }}>
|
||||||
<ActionIcon actionKey={e.actionKey} />
|
<ActionIcon actionKey={e.actionKey} />
|
||||||
@@ -340,7 +340,7 @@ function TasksInProgressCard({ tasks = [] }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No tasks in progress</div>
|
<div style={{ minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No tasks in progress</div>
|
||||||
) : (
|
) : (
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||||
<colgroup>
|
<colgroup>
|
||||||
@@ -373,13 +373,18 @@ function TasksInProgressCard({ tasks = [] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function HotItemsCard({ submissions, tasks }) {
|
function HotItemsCard({ submissions, tasks, isClient = false }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { sortKey, sortDir, toggle, sort } = useSortable('deadline');
|
const { sortKey, sortDir, toggle, sort } = useSortable('deadline');
|
||||||
const taskMap = new Map((tasks || []).map(t => [t.id, t]));
|
const taskMap = new Map((tasks || []).map(t => [t.id, t]));
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
const hotItems = (submissions || [])
|
const hotItems = (submissions || [])
|
||||||
.filter(s => s.is_hot && !seen.has(s.task_id) && seen.add(s.task_id))
|
.filter(s => {
|
||||||
|
const task = taskMap.get(s.task_id);
|
||||||
|
if (isClient) return task?.status === 'client_review' && !seen.has(s.task_id) && seen.add(s.task_id);
|
||||||
|
const activeStatuses = ['not_started', 'in_progress', 'client_review', 'on_hold'];
|
||||||
|
return s.is_hot && activeStatuses.includes(task?.status) && !seen.has(s.task_id) && seen.add(s.task_id);
|
||||||
|
})
|
||||||
.map(s => ({ ...s, task: taskMap.get(s.task_id) }))
|
.map(s => ({ ...s, task: taskMap.get(s.task_id) }))
|
||||||
.filter(s => s.task)
|
.filter(s => s.task)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
@@ -398,10 +403,14 @@ function HotItemsCard({ submissions, tasks }) {
|
|||||||
return (
|
return (
|
||||||
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column' }}>
|
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column' }}>
|
||||||
<div style={{ marginBottom: hotItems.length > 0 ? 14 : 0, flexShrink: 0 }}>
|
<div style={{ marginBottom: hotItems.length > 0 ? 14 : 0, flexShrink: 0 }}>
|
||||||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Hot Tasks</span>
|
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>
|
||||||
|
{isClient ? 'Tasks Ready For Review' : 'Hot Tasks'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{hotItems.length === 0 ? (
|
{hotItems.length === 0 ? (
|
||||||
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No hot tasks</div>
|
<div style={{ minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>
|
||||||
|
{isClient ? 'No tasks ready for review' : 'No hot tasks'}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||||
<colgroup>
|
<colgroup>
|
||||||
@@ -471,7 +480,7 @@ function ClientHighlightCard({ highlights }) {
|
|||||||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Client Highlight</span>
|
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Client Highlight</span>
|
||||||
</div>
|
</div>
|
||||||
{!highlights || highlights.length === 0 ? (
|
{!highlights || highlights.length === 0 ? (
|
||||||
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No data</div>
|
<div style={{ minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No data</div>
|
||||||
) : (
|
) : (
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||||
<colgroup>
|
<colgroup>
|
||||||
@@ -576,7 +585,7 @@ function TeamPerformanceCard({ tasks, profiles }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{people.slice(0, 5).length === 0 ? (
|
{people.slice(0, 5).length === 0 ? (
|
||||||
<div style={{ fontSize: 13, color: 'var(--text-muted)', flex: 1 }}>No completed tasks this month</div>
|
<div className="card-empty-center">No completed tasks this month</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||||
{people.slice(0, 5).map((p, i) => {
|
{people.slice(0, 5).map((p, i) => {
|
||||||
@@ -634,88 +643,148 @@ export default function TeamDashboard() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
async function loadTeam() {
|
async function loadDashboard() {
|
||||||
try {
|
try {
|
||||||
const cutoff = new Date();
|
const cutoff = new Date();
|
||||||
cutoff.setMonth(cutoff.getMonth() - CUTOFF_MONTHS);
|
cutoff.setMonth(cutoff.getMonth() - CUTOFF_MONTHS);
|
||||||
const cutoffStr = cutoff.toISOString();
|
const cutoffStr = cutoff.toISOString();
|
||||||
const [{ data: t }, { data: p }, { data: subs }, { data: profiles }, { data: activity }, { data: cos }, { data: memRows }] = await withTimeout(Promise.all([
|
|
||||||
supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_name, assigned_to, completed_at').gte('submitted_at', cutoffStr).order('submitted_at', { ascending: false }),
|
let scopedTaskIds = null;
|
||||||
supabase.from('projects').select('id, name, status, company_id'),
|
let scopedProjectIds = null;
|
||||||
supabase.from('submissions').select('task_id, version_number, deadline, type, submitted_by, submitted_by_name, is_hot, delivery:deliveries(sent_by, sent_at)').gte('submitted_at', cutoffStr).order('version_number', { ascending: false }),
|
let scopedCompanyIds = null;
|
||||||
|
|
||||||
|
if (!isTeam && isClient) {
|
||||||
|
const companyIds = [...new Set([...(currentUser?.companies?.map(c => c.company?.id || c.id) || []), ...(currentUser?.company_id ? [currentUser.company_id] : [])])].filter(Boolean);
|
||||||
|
scopedCompanyIds = companyIds;
|
||||||
|
if (companyIds.length > 0) {
|
||||||
|
const { data: projRows } = await supabase.from('projects').select('id, tasks(id)').in('company_id', companyIds);
|
||||||
|
scopedProjectIds = (projRows || []).map(p => p.id).filter(Boolean);
|
||||||
|
scopedTaskIds = (projRows || []).flatMap(p => (p.tasks || []).map(t => t.id)).filter(Boolean);
|
||||||
|
} else {
|
||||||
|
scopedProjectIds = [];
|
||||||
|
scopedTaskIds = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isTeam && isExternal) {
|
||||||
|
const uid = currentUser?.id;
|
||||||
|
const { data: memberData } = await supabase.from('project_members').select('project_id').eq('profile_id', uid);
|
||||||
|
scopedProjectIds = (memberData || []).map(m => m.project_id).filter(Boolean);
|
||||||
|
if ((scopedProjectIds || []).length > 0) {
|
||||||
|
const { data: projectTasks } = await supabase.from('tasks').select('id').in('project_id', scopedProjectIds);
|
||||||
|
scopedTaskIds = (projectTasks || []).map(row => row.id).filter(Boolean);
|
||||||
|
} else {
|
||||||
|
scopedTaskIds = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasksQuery = supabase.from('tasks')
|
||||||
|
.select('id, title, status, current_version, project_id, assigned_name, assigned_to, completed_at')
|
||||||
|
.gte('submitted_at', cutoffStr)
|
||||||
|
.order('submitted_at', { ascending: false });
|
||||||
|
if (isClient) {
|
||||||
|
if ((scopedProjectIds || []).length > 0) tasksQuery.in('project_id', scopedProjectIds);
|
||||||
|
else tasksQuery.eq('id', '__none__');
|
||||||
|
}
|
||||||
|
if (isExternal) {
|
||||||
|
if ((scopedProjectIds || []).length > 0) tasksQuery.in('project_id', scopedProjectIds);
|
||||||
|
else tasksQuery.eq('id', '__none__');
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectsQuery = supabase.from('projects').select('id, name, status, company_id');
|
||||||
|
if (isClient) {
|
||||||
|
if ((scopedCompanyIds || []).length > 0) projectsQuery.in('company_id', scopedCompanyIds);
|
||||||
|
else projectsQuery.eq('id', '__none__');
|
||||||
|
}
|
||||||
|
if (isExternal) {
|
||||||
|
if ((scopedProjectIds || []).length > 0) projectsQuery.in('id', scopedProjectIds);
|
||||||
|
else projectsQuery.eq('id', '__none__');
|
||||||
|
}
|
||||||
|
|
||||||
|
const submissionsQuery = supabase.from('submissions')
|
||||||
|
.select('task_id, version_number, deadline, type, submitted_by, submitted_by_name, is_hot, delivery:deliveries(sent_by, sent_at)')
|
||||||
|
.gte('submitted_at', cutoffStr)
|
||||||
|
.order('version_number', { ascending: false });
|
||||||
|
if (isClient) {
|
||||||
|
if ((scopedTaskIds || []).length > 0) submissionsQuery.in('task_id', scopedTaskIds);
|
||||||
|
else submissionsQuery.eq('task_id', '__none__');
|
||||||
|
}
|
||||||
|
if (isExternal) {
|
||||||
|
if ((scopedTaskIds || []).length > 0) submissionsQuery.in('task_id', scopedTaskIds);
|
||||||
|
else submissionsQuery.eq('task_id', '__none__');
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoicesPromise = isTeam
|
||||||
|
? supabase.from('invoices').select('total, status, company_id, created_at').in('status', ['sent', 'paid'])
|
||||||
|
: isClient
|
||||||
|
? ((scopedCompanyIds || []).length > 0
|
||||||
|
? supabase.from('invoices').select('total, status, company_id, created_at').in('company_id', scopedCompanyIds).in('status', ['sent', 'paid'])
|
||||||
|
: Promise.resolve({ data: [] }))
|
||||||
|
: supabase.from('subcontractor_invoices').select('total, status').eq('profile_id', currentUser?.id).in('status', ['submitted', 'approved', 'paid']);
|
||||||
|
|
||||||
|
const expensesPromise = isTeam
|
||||||
|
? supabase.from('expenses').select('amount')
|
||||||
|
: Promise.resolve({ data: [] });
|
||||||
|
|
||||||
|
const [{ data: t }, { data: p }, { data: subs }, { data: profiles }, { data: activity }, { data: cos }, { data: memRows }, { data: invs }, { data: exps }] = await withTimeout(Promise.all([
|
||||||
|
tasksQuery,
|
||||||
|
projectsQuery,
|
||||||
|
submissionsQuery,
|
||||||
supabase.from('profiles').select('id, role, name, email, company_id, brand_book_rate, avatar_url'),
|
supabase.from('profiles').select('id, role, name, email, company_id, brand_book_rate, avatar_url'),
|
||||||
supabase.from('activity_log').select('id, created_at, actor_id, actor_name, action, task_id, task_title, project_name, project_id').order('created_at', { ascending: false }).limit(20),
|
supabase.from('activity_log').select('id, created_at, actor_id, actor_name, action, task_id, task_title, project_name, project_id').order('created_at', { ascending: false }).limit(isTeam ? 20 : 50),
|
||||||
supabase.from('companies').select('id, name').order('name'),
|
supabase.from('companies').select('id, name').order('name'),
|
||||||
supabase.from('company_members').select('company_id, profile_id'),
|
supabase.from('company_members').select('company_id, profile_id'),
|
||||||
]), 30000, 'TeamDashboard load');
|
invoicesPromise,
|
||||||
|
expensesPromise,
|
||||||
|
]), 30000, 'Dashboard load');
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const projectIds = new Set((p || []).map(proj => proj.id));
|
||||||
|
const taskIds = new Set((t || []).map(task => task.id));
|
||||||
const roleById = new Map((profiles || []).map(pr => [pr.id, pr.role]));
|
const roleById = new Map((profiles || []).map(pr => [pr.id, pr.role]));
|
||||||
const roleByName = new Map((profiles || []).map(pr => [pr.name, pr.role]));
|
const roleByName = new Map((profiles || []).map(pr => [pr.name, pr.role]));
|
||||||
const tasksWithDeadlines = (t || []).map(task => ({ ...task, deadline: getDeadlineSourceSubmission(task, subs)?.deadline || null, assignee_role: roleById.get(task.assigned_to) || null }));
|
const tasksWithDeadlines = (t || []).map(task => ({ ...task, deadline: getDeadlineSourceSubmission(task, subs)?.deadline || null, assignee_role: roleById.get(task.assigned_to) || null }));
|
||||||
const subsWithRole = (subs || []).map(sub => ({ ...sub, submitter_role: roleById.get(sub.submitted_by) || null, delivery_sender_role: roleByName.get(sub.delivery?.sent_by) || null }));
|
const subsWithRole = (subs || []).map(sub => ({ ...sub, submitter_role: roleById.get(sub.submitted_by) || null, delivery_sender_role: roleByName.get(sub.delivery?.sent_by) || null }));
|
||||||
setTasks(tasksWithDeadlines); setProjects(p || []); setSubmissions(subsWithRole);
|
setTasks(tasksWithDeadlines);
|
||||||
|
setProjects(p || []);
|
||||||
|
setSubmissions(subsWithRole);
|
||||||
setClientProfiles((profiles || []).filter(pr => pr.role === 'client'));
|
setClientProfiles((profiles || []).filter(pr => pr.role === 'client'));
|
||||||
setAllProfiles(profiles || []); setAllCompanies(cos || []); setCompanyMemberships(memRows || []); setActivityLog(activity || []);
|
setAllProfiles(profiles || []);
|
||||||
writePageCache(CACHE_KEY, { tasks: tasksWithDeadlines, projects: p || [], submissions: subsWithRole, clientProfiles: (profiles || []).filter(pr => pr.role === 'client'), companies: cos || [], companyMemberships: memRows || [], activityLog: activity || [], teamInvoices: [] });
|
setAllCompanies(cos || []);
|
||||||
supabase.from('invoices').select('total, status, company_id, created_at').in('status', ['sent', 'paid']).then(({ data: invs }) => { if (invs && !cancelled) setTeamInvoices(invs); });
|
setCompanyMemberships(memRows || []);
|
||||||
supabase.from('expenses').select('amount').then(({ data: exps }) => { if (exps && !cancelled) setTeamExpenses(exps); });
|
const scopedActivity = isTeam
|
||||||
} catch (err) { console.error('TeamDashboard load failed:', err); }
|
? (activity || [])
|
||||||
|
: (activity || []).filter((entry) => {
|
||||||
|
if (entry.project_id && projectIds.has(entry.project_id)) return true;
|
||||||
|
if (entry.task_id && taskIds.has(entry.task_id)) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
setActivityLog(scopedActivity.slice(0, 20));
|
||||||
|
setTeamInvoices(isTeam ? (invs || []) : []);
|
||||||
|
setMyInvoices(!isTeam ? (invs || []) : []);
|
||||||
|
setTeamExpenses(exps || []);
|
||||||
|
|
||||||
|
if (isTeam) {
|
||||||
|
writePageCache(CACHE_KEY, {
|
||||||
|
tasks: tasksWithDeadlines,
|
||||||
|
projects: p || [],
|
||||||
|
submissions: subsWithRole,
|
||||||
|
clientProfiles: (profiles || []).filter(pr => pr.role === 'client'),
|
||||||
|
companies: cos || [],
|
||||||
|
companyMemberships: memRows || [],
|
||||||
|
activityLog: activity || [],
|
||||||
|
teamInvoices: invs || [],
|
||||||
|
allProfiles: profiles || [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) { console.error('Dashboard load failed:', err); }
|
||||||
finally { if (!cancelled) setLoading(false); }
|
finally { if (!cancelled) setLoading(false); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadClient() {
|
loadDashboard();
|
||||||
const uid = currentUser?.id;
|
|
||||||
const companyIds = [...new Set([...(currentUser?.companies?.map(c => c.company?.id || c.id) || []), ...(currentUser?.company_id ? [currentUser.company_id] : [])])].filter(Boolean);
|
|
||||||
try {
|
|
||||||
const { data: mySubData } = await supabase.from('submissions').select('task_id').eq('submitted_by', uid).eq('type', 'initial');
|
|
||||||
const myTaskIds = (mySubData || []).map(s => s.task_id);
|
|
||||||
const [{ data: t }, { data: p }, { data: subs }, { data: activity }, { data: invs }, { data: profiles }] = await withTimeout(Promise.all([
|
|
||||||
myTaskIds.length ? supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_name, assigned_to, completed_at').in('id', myTaskIds) : Promise.resolve({ data: [] }),
|
|
||||||
companyIds.length ? supabase.from('projects').select('id, name, status, company_id').in('company_id', companyIds) : Promise.resolve({ data: [] }),
|
|
||||||
myTaskIds.length ? supabase.from('submissions').select('task_id, version_number, deadline, type, submitted_by, submitted_by_name, is_hot').in('task_id', myTaskIds) : Promise.resolve({ data: [] }),
|
|
||||||
supabase.from('activity_log').select('id, created_at, actor_id, actor_name, action, task_id, task_title, project_name, project_id').order('created_at', { ascending: false }).limit(50),
|
|
||||||
companyIds.length ? supabase.from('invoices').select('total, status').in('company_id', companyIds).in('status', ['sent', 'paid']) : Promise.resolve({ data: [] }),
|
|
||||||
supabase.from('profiles').select('id, name, avatar_url'),
|
|
||||||
]), 20000, 'ClientDashboard load');
|
|
||||||
if (cancelled) return;
|
|
||||||
const projectIds = new Set((p || []).map(proj => proj.id));
|
|
||||||
setTasks((t || []).map(task => ({ ...task, deadline: getDeadlineSourceSubmission(task, subs)?.deadline || null })));
|
|
||||||
setProjects(p || []); setSubmissions(subs || []); setAllProfiles(profiles || []);
|
|
||||||
setActivityLog((activity || []).filter(e => !e.project_id || projectIds.has(e.project_id)).slice(0, 20));
|
|
||||||
setMyInvoices(invs || []);
|
|
||||||
} catch (err) { console.error('ClientDashboard load failed:', err); }
|
|
||||||
finally { if (!cancelled) setLoading(false); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadExternal() {
|
|
||||||
const uid = currentUser?.id;
|
|
||||||
try {
|
|
||||||
const { data: memberData } = await supabase.from('project_members').select('project_id').eq('profile_id', uid);
|
|
||||||
const myProjectIds = (memberData || []).map(m => m.project_id);
|
|
||||||
const [{ data: t }, { data: p }, { data: subs }, { data: activity }, { data: subInvs }, { data: profiles }] = await withTimeout(Promise.all([
|
|
||||||
supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_name, assigned_to, completed_at').eq('assigned_to', uid),
|
|
||||||
myProjectIds.length ? supabase.from('projects').select('id, name, status, company_id').in('id', myProjectIds) : Promise.resolve({ data: [] }),
|
|
||||||
supabase.from('submissions').select('task_id, version_number, deadline, type, submitted_by, submitted_by_name, is_hot').eq('submitted_by', uid),
|
|
||||||
supabase.from('activity_log').select('id, created_at, actor_id, actor_name, action, task_id, task_title, project_name, project_id').order('created_at', { ascending: false }).limit(50),
|
|
||||||
supabase.from('subcontractor_invoices').select('total, status').eq('profile_id', uid).in('status', ['submitted', 'approved', 'paid']),
|
|
||||||
supabase.from('profiles').select('id, name, avatar_url'),
|
|
||||||
]), 20000, 'ExternalDashboard load');
|
|
||||||
if (cancelled) return;
|
|
||||||
const projectIdSet = new Set(myProjectIds);
|
|
||||||
setTasks((t || []).map(task => ({ ...task, deadline: getDeadlineSourceSubmission(task, subs)?.deadline || null })));
|
|
||||||
setProjects(p || []); setSubmissions(subs || []); setAllProfiles(profiles || []);
|
|
||||||
setActivityLog((activity || []).filter(e => !e.project_id || projectIdSet.has(e.project_id)).slice(0, 20));
|
|
||||||
setMyInvoices(subInvs || []);
|
|
||||||
} catch (err) { console.error('ExternalDashboard load failed:', err); }
|
|
||||||
finally { if (!cancelled) setLoading(false); }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isTeam) loadTeam();
|
|
||||||
else if (isClient) loadClient();
|
|
||||||
else if (isExternal) loadExternal();
|
|
||||||
|
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [isTeam, isClient, isExternal, currentUser?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [isTeam, isClient, isExternal, currentUser?.id, currentUser?.company_id, currentUser?.companies]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const teamHighlights = useMemo(() =>
|
const teamHighlights = useMemo(() =>
|
||||||
isTeam ? buildClientHighlights(allCompanies, projects, tasks, clientProfiles, companyMemberships, teamInvoices)
|
isTeam ? buildClientHighlights(allCompanies, projects, tasks, clientProfiles, companyMemberships, teamInvoices)
|
||||||
@@ -796,7 +865,7 @@ export default function TeamDashboard() {
|
|||||||
<MiniCalendar items={calendarItems} />
|
<MiniCalendar items={calendarItems} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginTop: 24 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginTop: 24 }}>
|
||||||
<HotItemsCard submissions={submissions} tasks={tasks} />
|
<HotItemsCard submissions={submissions} tasks={tasks} isClient={isClient} />
|
||||||
<TeamPerformanceCard tasks={tasks} profiles={allProfiles} />
|
<TeamPerformanceCard tasks={tasks} profiles={allProfiles} />
|
||||||
</div>
|
</div>
|
||||||
{isTeam && (
|
{isTeam && (
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { supabase } from '../../lib/supabase';
|
|||||||
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
||||||
import { exportCPAPackage, generateSubcontractorPOPDF } from '../../lib/invoice';
|
import { exportCPAPackage, generateSubcontractorPOPDF } from '../../lib/invoice';
|
||||||
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
|
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
|
||||||
|
import { popupOverlayStyle, popupSurfaceStyle } from '../../lib/popupStyles';
|
||||||
|
|
||||||
const CATEGORIES = ['Software', 'Contractor', 'Advertising', 'Office', 'Travel', 'Meals', 'Equipment', 'Other'];
|
const CATEGORIES = ['Software', 'Contractor', 'Advertising', 'Office', 'Travel', 'Meals', 'Equipment', 'Other'];
|
||||||
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
|
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
|
||||||
@@ -707,8 +708,8 @@ export default function Invoices() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showExpenseForm && (
|
{showExpenseForm && (
|
||||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }} onClick={cancelExpenseEdit}>
|
<div style={popupOverlayStyle} onClick={cancelExpenseEdit}>
|
||||||
<div style={{ background: 'var(--card-bg)', borderRadius: 4, padding: 28, width: '100%', maxWidth: 520, border: '1px solid var(--border)', boxShadow: '0 24px 64px rgba(0,0,0,0.5)', maxHeight: '90vh', overflowY: 'auto' }} onClick={e => e.stopPropagation()}>
|
<div style={{ ...popupSurfaceStyle, padding: 28, width: '100%', maxWidth: 520, maxHeight: '90vh', overflowY: 'auto' }} onClick={e => e.stopPropagation()}>
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 20 }}>
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 20 }}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>{editingExpenseId ? 'Edit Expense' : 'New Expense'}</div>
|
<div style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>{editingExpenseId ? 'Edit Expense' : 'New Expense'}</div>
|
||||||
@@ -215,10 +215,29 @@ Deno.serve(async (req) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (op === 'download') {
|
if (op === 'download') {
|
||||||
const res = await fetch(`${FBQ_URL}/api/raw?source=${SOURCE}&path=${encodeURIComponent(resolvedPath)}`, { headers: fbqHeaders });
|
const rawUrl = `${FBQ_URL}/api/raw?source=${SOURCE}&path=${encodeURIComponent(resolvedPath)}`;
|
||||||
const ct = res.headers.get('content-type') ?? 'application/octet-stream';
|
const rawRes = await fetch(rawUrl, { headers: fbqHeaders });
|
||||||
const cd = res.headers.get('content-disposition') ?? `attachment; filename="${resolvedPath.split('/').pop()}"`;
|
|
||||||
return new Response(res.body, { status: res.status, headers: { ...cors(origin), 'Content-Type': ct, 'Content-Disposition': cd } });
|
if (rawRes.ok) {
|
||||||
|
const ct = rawRes.headers.get('content-type') ?? 'application/octet-stream';
|
||||||
|
const cd = rawRes.headers.get('content-disposition') ?? `attachment; filename="${resolvedPath.split('/').pop()}"`;
|
||||||
|
return new Response(rawRes.body, { status: rawRes.status, headers: { ...cors(origin), 'Content-Type': ct, 'Content-Disposition': cd } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawErrorText = await rawRes.text();
|
||||||
|
const shouldFallbackToResourcesDownload = rawRes.status === 400 && rawErrorText.toLowerCase().includes('no files specified');
|
||||||
|
if (!shouldFallbackToResourcesDownload) {
|
||||||
|
return new Response(rawErrorText, { status: rawRes.status, headers: { ...cors(origin), 'Content-Type': 'application/json' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const directDownloadUrl = `${FBQ_URL}/api/resources/download?source=${SOURCE}&file=${encodeURIComponent(resolvedPath)}`;
|
||||||
|
const directRes = await fetch(directDownloadUrl, { headers: fbqHeaders });
|
||||||
|
const directCt = directRes.headers.get('content-type') ?? 'application/octet-stream';
|
||||||
|
const directCd = directRes.headers.get('content-disposition') ?? `attachment; filename="${resolvedPath.split('/').pop() ?? 'download'}"`;
|
||||||
|
return new Response(directRes.body, {
|
||||||
|
status: directRes.status,
|
||||||
|
headers: { ...cors(origin), 'Content-Type': directCt, 'Content-Disposition': directCd },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (op === 'upload') {
|
if (op === 'upload') {
|
||||||
|
|||||||
Reference in New Issue
Block a user