Compare commits

...

4 Commits

Author SHA1 Message Date
Krao Hasanee 2cfbbec9dc cleanup: remove dead code, drop unused deps, refactor derived-state effects
Lint went from 84 problems to 0. Build passes.

Dead code:
- TeamInvoices: drop the orphaned Subcontractor PO block (updateSubcontractorPO
  plus send/ready-to-pay/paid/reopen/cancel/delete/download/CPA-export handlers
  and ~25 derived vars). That feature moved to the routed
  TeamCreateSubcontractorPO / TeamSubcontractorPODetail pages in the Layout2
  redesign; these were leftovers. 2100 -> 1880 lines.
- Tasks: drop unrendered project sort/tab/completion logic in TasksPageShell.
- Remove 59 unused vars, imports, and handlers across 12 other files.

Removed wasted queries:
- TeamInvoices ran a nested subcontractor_payments join (profiles, projects,
  companies, PO items, tasks) on every page load into state nothing read.
- TeamInvoices and ExternalMyInvoices each fetched a task-type map on invoice
  open and discarded it; the ExternalMyInvoices one blocked the detail popup.
- RequestForm queried sign_families on mount into unread state.

Dependencies:
- Drop heic2any (zero imports). heic-to is the one in use and already lazy-loads.

Effects and exports:
- RequestForm: remove 4 effects. Sign-row sizing and company-change resets move
  into their change handlers; single-company selection is derived during render
  rather than written back into form state.
- FilterDropdown: measure position on click instead of in an effect, which also
  removes a one-frame stale-position flicker.
- BrandBook: latest-ref pattern so the 900ms address debounce is not reset by
  handler identity.
- ClientMyInvoices: hoist MONTHS to module scope.
- Split non-component exports: POPUP_FIELD_LABEL moves to lib/popupStyles.js;
  resolveProfileAvatar and useLiveClock are internal-only; getGreeting deleted
  as nothing referenced it.

Companies.jsx keeps one scoped eslint-disable with a reason: load() only
setStates after awaiting its queries, so set-state-in-effect is a false positive
there, and load() is shared with the create/delete handlers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 15:13:01 -04:00
Krao Hasanee 1f52b6e96c fix: sub billing only applies to work an external sub delivered
Report was flagging every unbilled delivered version as "Not Invoiced"
for sub billing, even when a team member delivered it — team work is
billed to the client directly and is never sub-invoiced. Now traces
each version's actual deliverer and shows N/A when it's a team member,
regardless of who delivered earlier versions on the same task.

Also: SubcontractorInvoiceForm no longer masks fetch errors as "No
completed tasks available to invoice" — the real error now shows in
the Completed Tasks panel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 20:15:04 -04:00
Krao Hasanee 2c1ff61b29 fix: popups no longer close on backdrop click, only via explicit close/cancel
Clicking the dimmed area outside a modal (+Task, +Invoice, expense,
review, etc.) accidentally dismissed it and lost in-progress input.
Every popup already has an explicit Cancel/Close/✕ button, so the
backdrop click handler is removed; also drops the now-dead
onClose/blockClose prop plumbing that only existed to support it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 16:45:37 -04:00
Krao Hasanee bf70646a54 fix: sub invoice eligibility follows delivery author, not task assignment
Completed Tasks list scoped by task.assigned_to meant a reassigned task
made the original sub's already-delivered versions permanently unbillable
by anyone. Now scoped by deliveries.sent_by directly, so each version
stays billable by whoever actually delivered it, independent of who
currently owns the task.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 16:04:24 -04:00
25 changed files with 179 additions and 532 deletions
-12
View File
@@ -37,18 +37,6 @@ function safeName(value, fallback = '') {
return cleaned || fallback; return cleaned || fallback;
} }
function parseBody(body) {
if (!body) return {};
if (typeof body === 'string') {
try {
return JSON.parse(body);
} catch {
return {};
}
}
return body;
}
async function readBody(req) { async function readBody(req) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const chunks = []; const chunks = [];
-7
View File
@@ -10,7 +10,6 @@
"dependencies": { "dependencies": {
"@supabase/supabase-js": "^2.99.3", "@supabase/supabase-js": "^2.99.3",
"heic-to": "^1.4.2", "heic-to": "^1.4.2",
"heic2any": "^0.0.4",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.7", "jspdf-autotable": "^5.0.7",
"jszip": "^3.10.1", "jszip": "^3.10.1",
@@ -2026,12 +2025,6 @@
"integrity": "sha512-y69thwxfNcEm2Vk8lbOD/cMabnvMJyOREfJYiCHcXCDqlfcPyJoBhyRc8+iDe1B95LRfpbTOpzxzY1xbRkdwBA==", "integrity": "sha512-y69thwxfNcEm2Vk8lbOD/cMabnvMJyOREfJYiCHcXCDqlfcPyJoBhyRc8+iDe1B95LRfpbTOpzxzY1xbRkdwBA==",
"license": "LGPL-3.0" "license": "LGPL-3.0"
}, },
"node_modules/heic2any": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/heic2any/-/heic2any-0.0.4.tgz",
"integrity": "sha512-3lLnZiDELfabVH87htnRolZ2iehX9zwpRyGNz22GKXIu0fznlblf0/ftppXKNqS26dqFSeqfIBhAmAj/uSp0cA==",
"license": "MIT"
},
"node_modules/hermes-estree": { "node_modules/hermes-estree": {
"version": "0.25.1", "version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
-1
View File
@@ -12,7 +12,6 @@
"dependencies": { "dependencies": {
"@supabase/supabase-js": "^2.99.3", "@supabase/supabase-js": "^2.99.3",
"heic-to": "^1.4.2", "heic-to": "^1.4.2",
"heic2any": "^0.0.4",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.7", "jspdf-autotable": "^5.0.7",
"jszip": "^3.10.1", "jszip": "^3.10.1",
+1 -1
View File
@@ -1,6 +1,6 @@
import { lazy, Suspense, Component } from 'react'; import { lazy, Suspense, Component } from 'react';
import { BrowserRouter, Routes, Route, Navigate, useParams } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate, useParams } from 'react-router-dom';
import { AuthProvider, useAuth } from './context/AuthContext'; import { AuthProvider } from './context/AuthContext';
import ProtectedRoute from './components/ProtectedRoute'; import ProtectedRoute from './components/ProtectedRoute';
import PageLoader from './components/PageLoader'; import PageLoader from './components/PageLoader';
+1 -2
View File
@@ -23,7 +23,6 @@ export default function FilterDropdown({ value, onChange, options }) {
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
place();
function onDown(e) { function onDown(e) {
if (btnRef.current?.contains(e.target) || menuRef.current?.contains(e.target)) return; if (btnRef.current?.contains(e.target) || menuRef.current?.contains(e.target)) return;
setOpen(false); setOpen(false);
@@ -48,7 +47,7 @@ export default function FilterDropdown({ value, onChange, options }) {
<button <button
ref={btnRef} ref={btnRef}
type="button" type="button"
onClick={() => setOpen(o => !o)} onClick={() => { if (!open) place(); setOpen(o => !o); }}
aria-label="Filter" aria-label="Filter"
title="Filter" title="Filter"
style={{ style={{
+1 -13
View File
@@ -1,22 +1,10 @@
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles'; import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
import PageLoader from './PageLoader'; import PageLoader from './PageLoader';
export const POPUP_FIELD_LABEL = {
fontSize: 11,
fontWeight: 500,
color: 'var(--text-secondary)',
textTransform: 'uppercase',
letterSpacing: 0.8,
display: 'block',
marginBottom: 4,
};
export default function InvoiceDetailPopup({ export default function InvoiceDetailPopup({
title, title,
subtitle, subtitle,
headerRight, headerRight,
onClose,
blockClose = false,
metaContent, // JSX fields rendered in flat meta grid (no cards) metaContent, // JSX fields rendered in flat meta grid (no cards)
metaActions, // optional JSX rendered right-aligned beside meta grid (e.g. Edit Dates) metaActions, // optional JSX rendered right-aligned beside meta grid (e.g. Edit Dates)
metaCols = 4, // number of grid columns for meta strip metaCols = 4, // number of grid columns for meta strip
@@ -25,7 +13,7 @@ export default function InvoiceDetailPopup({
children, children,
}) { }) {
return ( return (
<div style={popupOverlayStyle} onClick={() => { if (!blockClose) onClose?.(); }}> <div style={popupOverlayStyle}>
<div <div
style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }}
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
+1 -1
View File
@@ -2,7 +2,7 @@ function normalizeName(value) {
return String(value || '').trim().toLowerCase(); return String(value || '').trim().toLowerCase();
} }
export function resolveProfileAvatar({ function resolveProfileAvatar({
avatarUrl = '', avatarUrl = '',
profile = null, profile = null,
profileId = '', profileId = '',
+42 -45
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect } from 'react';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
import { serviceTypes } from '../data/mockData'; import { serviceTypes } from '../data/mockData';
import FileAttachment from './FileAttachment'; import FileAttachment from './FileAttachment';
@@ -49,51 +49,23 @@ export default function RequestForm({
requestedBy: showRequester ? (currentUser?.id || '') : '', requestedBy: showRequester ? (currentUser?.id || '') : '',
...(initialValues || {}), ...(initialValues || {}),
})); }));
const prevCompanyIdRef = useRef(initialValues?.companyId || initialCompanyId || null);
const [files, setFiles] = useState([]); const [files, setFiles] = useState([]);
const [existingProjects, setExistingProjects] = useState([]); const [existingProjects, setExistingProjects] = useState([]);
const [customProjects, setCustomProjects] = useState([]); const [customProjects, setCustomProjects] = useState([]);
const [isTypingProject, setIsTypingProject] = useState(false); const [isTypingProject, setIsTypingProject] = useState(false);
const [newProjectName, setNewProjectName] = useState(''); const [newProjectName, setNewProjectName] = useState('');
const [companyUsers, setCompanyUsers] = useState([]); const [companyUsers, setCompanyUsers] = useState([]);
const [signFamilies, setSignFamilies] = useState([]);
const [signCount, setSignCount] = useState(''); const [signCount, setSignCount] = useState('');
const [signs, setSigns] = useState([]); const [signs, setSigns] = useState([]);
const [localError, setLocalError] = useState(''); const [localError, setLocalError] = useState('');
const usesSignFields = form.serviceType === 'Brand Book'; const usesSignFields = form.serviceType === 'Brand Book';
useEffect(() => { // Single-company user: lock selection to their one company (derived, not stored).
supabase.from('sign_families').select('name').order('sort_order').then(({ data }) => setSignFamilies((data || []).map(r => r.name))); const companyId = form.companyId || (companies.length === 1 ? companies[0].id : '') || initialCompanyId;
}, []);
useEffect(() => { useEffect(() => {
if (!usesSignFields) { setSignCount(''); setSigns([]); } if (!companyId) return;
}, [usesSignFields]);
useEffect(() => {
const n = Math.max(0, parseInt(signCount) || 0);
setSigns(prev => {
if (n < prev.length) return prev.slice(0, n);
if (n > prev.length) {
const extra = Array.from({ length: n - prev.length }, () => ({ id: crypto.randomUUID(), signName: '', signFamily: '' }));
return [...prev, ...extra];
}
return prev;
});
}, [signCount]);
const companyId = form.companyId || initialCompanyId;
// Single-company user: lock selection to their one company.
useEffect(() => {
if (companies.length === 1 && !form.companyId) {
setForm(f => ({ ...f, companyId: companies[0].id }));
}
}, [companies, form.companyId]);
useEffect(() => {
if (!companyId) { setExistingProjects([]); setCompanyUsers([]); return; }
Promise.all([ Promise.all([
supabase.from('projects').select('id, name').eq('company_id', companyId).order('name'), supabase.from('projects').select('id, name').eq('company_id', companyId).order('name'),
showRequester showRequester
@@ -117,13 +89,6 @@ export default function RequestForm({
setCompanyUsers(merged); setCompanyUsers(merged);
} }
}); });
if (companyId !== prevCompanyIdRef.current) {
setForm(f => ({ ...f, project: '', requestedBy: '' }));
setCustomProjects([]);
setIsTypingProject(false);
setNewProjectName('');
}
prevCompanyIdRef.current = companyId;
}, [companyId]); // eslint-disable-line react-hooks/exhaustive-deps }, [companyId]); // eslint-disable-line react-hooks/exhaustive-deps
const set = (field) => (e) => { const set = (field) => (e) => {
@@ -136,9 +101,41 @@ export default function RequestForm({
setSigns(prev => prev.map(s => s.id === id ? { ...s, [field]: value } : s)); setSigns(prev => prev.map(s => s.id === id ? { ...s, [field]: value } : s));
}; };
// Sign rows follow the count field; resized on edit rather than in an effect.
const handleSignCountChange = (e) => {
const raw = e.target.value;
setSignCount(raw);
const n = Math.max(0, parseInt(raw) || 0);
setSigns(prev => {
if (n < prev.length) return prev.slice(0, n);
if (n > prev.length) {
const extra = Array.from({ length: n - prev.length }, () => ({ id: crypto.randomUUID(), signName: '', signFamily: '' }));
return [...prev, ...extra];
}
return prev;
});
};
const handleServiceTypeChange = (e) => {
set('serviceType')(e);
if (e.target.value !== 'Brand Book') { setSignCount(''); setSigns([]); }
};
// Switching company invalidates the project/requester picked under the old one.
const handleCompanyChange = (e) => {
setForm(f => ({ ...f, companyId: e.target.value, project: '', requestedBy: '' }));
setCustomProjects([]);
setIsTypingProject(false);
setNewProjectName('');
};
// No company selected yet means nothing loaded applies, so read through as empty.
const activeProjects = companyId ? existingProjects : [];
const activeCompanyUsers = companyId ? companyUsers : [];
const allProjectNames = [ const allProjectNames = [
...existingProjects.map(p => p.name), ...activeProjects.map(p => p.name),
...customProjects.filter(name => !existingProjects.some(p => p.name === name)), ...customProjects.filter(name => !activeProjects.some(p => p.name === name)),
]; ];
const handleProjectSelect = (e) => { const handleProjectSelect = (e) => {
@@ -164,7 +161,7 @@ export default function RequestForm({
const showCompanySelect = companies.length > 1 || showRequester; const showCompanySelect = companies.length > 1 || showRequester;
const requesterOptions = [ const requesterOptions = [
...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)` }] : []), ...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)` }] : []),
...companyUsers.filter(u => u.id !== currentUser?.id), ...activeCompanyUsers.filter(u => u.id !== currentUser?.id),
]; ];
const handleSubmit = (e) => { const handleSubmit = (e) => {
@@ -230,7 +227,7 @@ export default function RequestForm({
) : showCompanySelect ? ( ) : showCompanySelect ? (
<div className="form-group"> <div className="form-group">
<label style={modalLabelStyle}>Company *</label> <label style={modalLabelStyle}>Company *</label>
<select value={form.companyId} onChange={e => setForm(f => ({ ...f, companyId: e.target.value }))} required style={modalInputStyle}> <select value={companyId} onChange={handleCompanyChange} 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>)}
</select> </select>
@@ -265,7 +262,7 @@ export default function RequestForm({
<div className="grid-2"> <div className="grid-2">
<div className="form-group"> <div className="form-group">
<label style={modalLabelStyle}>Service Type *</label> <label style={modalLabelStyle}>Service Type *</label>
<select value={form.serviceType} onChange={set('serviceType')} required style={modalInputStyle}> <select value={form.serviceType} onChange={handleServiceTypeChange} 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>
@@ -315,7 +312,7 @@ export default function RequestForm({
max="50" max="50"
placeholder="How many signs?" placeholder="How many signs?"
value={signCount} value={signCount}
onChange={e => setSignCount(e.target.value)} onChange={handleSignCountChange}
required required
style={{ ...modalInputStyle, width: '100%' }} style={{ ...modalInputStyle, width: '100%' }}
/> />
+36 -45
View File
@@ -5,10 +5,12 @@ import { supabase } from '../lib/supabase';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { sendEmail } from '../lib/email'; import { sendEmail } from '../lib/email';
import { isCompletedVersionEligible } from '../lib/invoiceVersionRules'; import { isCompletedVersionEligible } from '../lib/invoiceVersionRules';
import { isReviewShadowDescription } from '../lib/taskVersions';
import { useActionLock } from '../hooks/useActionLock'; import { useActionLock } from '../hooks/useActionLock';
const INVOICE_TODAY = new Date().toISOString().split('T')[0]; const INVOICE_TODAY = new Date().toISOString().split('T')[0];
const REVISION_RATE = 30; const REVISION_RATE = 30;
const ELIGIBLE_TASK_STATUSES = new Set(['not_started', 'in_progress', 'on_hold', 'client_review', 'client_approved', 'invoiced', 'paid']);
const FIELD_LABEL_STYLE = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, display: 'block', marginBottom: 4 }; const FIELD_LABEL_STYLE = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, display: 'block', marginBottom: 4 };
const FIELD_INPUT_STYLE = { minHeight: 42, margin: 0 }; const FIELD_INPUT_STYLE = { minHeight: 42, margin: 0 };
const FINANCE_MODAL_INPUT_STYLE = { const FINANCE_MODAL_INPUT_STYLE = {
@@ -113,20 +115,20 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o
setLoadingTasks(true); setLoadingTasks(true);
setError(''); setError('');
try { try {
const [{ data: tasks, error: tasksError }, { data: existingInvoices, error: invoicesError }, { data: nextNum, error: nextNumError }] = await Promise.all([ const [{ data: deliveryRows, error: deliveriesError }, { data: existingInvoices, error: invoicesError }, { data: nextNum, error: nextNumError }] = await Promise.all([
// Scoped by who actually delivered each version, not by current task assignment —
// a task can be reassigned mid-flight and the original sub still needs to bill
// for the version(s) they personally delivered.
supabase supabase
.from('tasks') .from('deliveries')
.select('id, title, status, current_version, project:projects(name)') .select('version_number, sent_by, submission:submissions!inner(task_id, description, task:tasks!inner(id, title, status, current_version, project:projects(name)))'),
.in('status', ['not_started', 'in_progress', 'on_hold', 'client_review', 'client_approved', 'invoiced', 'paid'])
.eq('assigned_to', currentUser.id)
.order('title'),
supabase supabase
.from('subcontractor_invoices') .from('subcontractor_invoices')
.select('id, status, items:subcontractor_invoice_items(task_id, version_number, description)') .select('id, status, items:subcontractor_invoice_items(task_id, version_number, description)')
.in('status', ['submitted', 'paid']), .in('status', ['submitted', 'paid']),
supabase.rpc('get_next_sub_invoice_number'), supabase.rpc('get_next_sub_invoice_number'),
]); ]);
if (tasksError) throw tasksError; if (deliveriesError) throw deliveriesError;
if (invoicesError) throw invoicesError; if (invoicesError) throw invoicesError;
if (nextNumError) throw nextNumError; if (nextNumError) throw nextNumError;
@@ -141,46 +143,31 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o
}).filter(Boolean)) }).filter(Boolean))
); );
const taskIds = (tasks || []).map((task) => task.id).filter(Boolean); const myName = (currentUser.name || '').trim().toLowerCase();
const { data: submissionRows, error: submissionsError } = taskIds.length > 0 const unitsByKey = new Map();
? await supabase for (const delivery of (deliveryRows || [])) {
.from('submissions') if ((delivery.sent_by || '').trim().toLowerCase() !== myName) continue;
.select('task_id, deliveries(version_number, sent_by, sent_at)') const submission = delivery.submission;
.in('task_id', taskIds) const task = submission?.task;
: { data: [], error: null }; if (!task || !ELIGIBLE_TASK_STATUSES.has(task.status)) continue;
if (submissionsError) throw submissionsError; if (isReviewShadowDescription(submission.description)) continue;
const version = Number(delivery.version_number || 0);
const deliveriesByTask = new Map(); if (!isCompletedVersionEligible(task, version)) continue;
for (const row of (submissionRows || [])) { const key = `${task.id}:${version}`;
const taskId = row.task_id; if (invoicedUnitKeys.has(key) || unitsByKey.has(key)) continue;
if (!taskId) continue; unitsByKey.set(key, {
const bucket = deliveriesByTask.get(taskId) || new Map(); key,
for (const delivery of asArray(row.deliveries)) { task_id: task.id,
if ((delivery.sent_by || '').trim().toLowerCase() !== (currentUser.name || '').trim().toLowerCase()) continue; version_number: version,
const version = Number(delivery.version_number || 0); is_revision: version > 0,
if (!bucket.has(version)) bucket.set(version, delivery); title: task.title,
} project_name: task.project?.name || '',
deliveriesByTask.set(taskId, bucket); description: `${task.project?.name ? `${task.project.name}` : ''}${task.title} ${versionLabel(version)}`,
rate: subcontractorVersionRate(version, rate),
});
} }
const units = (tasks || []).flatMap((task) => { const units = [...unitsByKey.values()].sort((a, b) => a.title.localeCompare(b.title) || a.version_number - b.version_number);
const versions = [...(deliveriesByTask.get(task.id)?.keys() || [])].sort((a, b) => a - b);
return versions
.filter((version) => isCompletedVersionEligible(task, version))
.map((version) => {
const key = `${task.id}:${version}`;
return {
key,
task_id: task.id,
version_number: version,
is_revision: version > 0,
title: task.title,
project_name: task.project?.name || '',
description: `${task.project?.name ? `${task.project.name}` : ''}${task.title} ${versionLabel(version)}`,
rate: subcontractorVersionRate(version, rate),
};
});
}).filter((unit) => !invoicedUnitKeys.has(unit.key));
setCompletedTasks(units); setCompletedTasks(units);
} catch (err) { } catch (err) {
@@ -360,6 +347,8 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o
</div> </div>
{loadingTasks ? ( {loadingTasks ? (
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>Loading</div> <div style={{ fontSize: 12, color: 'var(--text-muted)' }}>Loading</div>
) : error ? (
<div style={{ fontSize: 12, color: 'var(--danger)' }}>{error}</div>
) : completedTasks.length === 0 ? ( ) : completedTasks.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>No completed tasks available to invoice.</div> <div style={{ fontSize: 12, color: 'var(--text-muted)' }}>No completed tasks available to invoice.</div>
) : ( ) : (
@@ -455,6 +444,8 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o
</div> </div>
{loadingTasks ? ( {loadingTasks ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading...</p> <p style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading...</p>
) : error ? (
<p style={{ fontSize: 13, color: 'var(--danger)' }}>{error}</p>
) : completedTasks.length === 0 ? ( ) : completedTasks.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>No completed tasks available to invoice.</p> <p style={{ fontSize: 13, color: 'var(--text-muted)' }}>No completed tasks available to invoice.</p>
) : ( ) : (
+1 -8
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
export function useLiveClock() { function useLiveClock() {
const [now, setNow] = useState(() => new Date()); const [now, setNow] = useState(() => new Date());
useEffect(() => { useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000); const id = setInterval(() => setNow(new Date()), 1000);
@@ -20,10 +20,3 @@ export function DashboardBanner() {
</div> </div>
); );
} }
export function getGreeting() {
const h = new Date().getHours();
if (h < 12) return 'Good morning';
if (h < 17) return 'Good afternoon';
return 'Good evening';
}
+1 -1
View File
@@ -22,6 +22,6 @@ export function getRevisionChargeQuantity(versionNumber, revisionType) {
// Parses version number from a subcontractor invoice item description like // Parses version number from a subcontractor invoice item description like
// "Project • Task R01". Legacy fallback only — new rows store version_number directly. // "Project • Task R01". Legacy fallback only — new rows store version_number directly.
export function parseVersionFromItemDescription(description = '') { export function parseVersionFromItemDescription(description = '') {
const match = String(description).match(/[\-]\s*R(\d{2})\b/i); const match = String(description).match(/[-]\s*R(\d{2})\b/i);
return match ? Number(match[1]) : 0; return match ? Number(match[1]) : 0;
} }
+10
View File
@@ -21,6 +21,16 @@ export const popupSurfaceStyle = {
boxShadow: 'var(--popup-shadow)', boxShadow: 'var(--popup-shadow)',
}; };
export const POPUP_FIELD_LABEL = {
fontSize: 11,
fontWeight: 500,
color: 'var(--text-secondary)',
textTransform: 'uppercase',
letterSpacing: 0.8,
display: 'block',
marginBottom: 4,
};
export const popupMenuStyle = { export const popupMenuStyle = {
background: 'var(--popup-bg)', background: 'var(--popup-bg)',
border: '1px solid var(--border)', border: '1px solid var(--border)',
+8 -5
View File
@@ -159,7 +159,6 @@ export default function BrandBook() {
const projectLogoRef = useRef(); const projectLogoRef = useRef();
const clientLogoRef = useRef(); const clientLogoRef = useRef();
const [filterCompany, setFilterCompany] = useState(''); const [filterCompany, setFilterCompany] = useState('');
const [selectedBookIds, setSelectedBookIds] = useState([]);
const { sortKey: bbSortKey, sortDir: bbSortDir, toggle: bbToggle, sort: bbSort } = useSortable('updated_at'); const { sortKey: bbSortKey, sortDir: bbSortDir, toggle: bbToggle, sort: bbSort } = useSortable('updated_at');
useEffect(() => { useEffect(() => {
@@ -175,7 +174,8 @@ export default function BrandBook() {
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
if (address.includes(',') && address.length >= 12) { if (address.includes(',') && address.length >= 12) {
loadMapsFromAddress(address, { silent: true }); // Called through a ref so the debounce isn't reset by the handler's identity.
loadMapsFromAddressRef.current(address, { silent: true });
} }
}, 900); }, 900);
@@ -222,7 +222,6 @@ export default function BrandBook() {
setLoadingBooks(true); setLoadingBooks(true);
const { data } = await supabase.from('brand_books').select('*').order('updated_at', { ascending: false }); const { data } = await supabase.from('brand_books').select('*').order('updated_at', { ascending: false });
setSavedBooks(data || []); setSavedBooks(data || []);
setSelectedBookIds(prev => prev.filter(id => (data || []).some(book => book.id === id)));
setLoadingBooks(false); setLoadingBooks(false);
}; };
@@ -235,6 +234,8 @@ export default function BrandBook() {
setBookInfo(b => ({ ...b, revision: normalizeRevision(b.revision) })); setBookInfo(b => ({ ...b, revision: normalizeRevision(b.revision) }));
}; };
const loadMapsFromAddressRef = useRef(null);
const loadMapsFromAddress = async (address = bookInfo.customerAddress, { silent = false, feature = null } = {}) => { const loadMapsFromAddress = async (address = bookInfo.customerAddress, { silent = false, feature = null } = {}) => {
const trimmed = String(address || '').trim(); const trimmed = String(address || '').trim();
if (!trimmed) { if (!trimmed) {
@@ -290,6 +291,10 @@ export default function BrandBook() {
} }
}; };
useEffect(() => {
loadMapsFromAddressRef.current = loadMapsFromAddress;
});
const handleCustomerAddressChange = (e) => { const handleCustomerAddressChange = (e) => {
const value = e.target.value; const value = e.target.value;
setBookInfo(b => ({ ...b, customerAddress: value, siteAddress: value })); setBookInfo(b => ({ ...b, customerAddress: value, siteAddress: value }));
@@ -2353,7 +2358,6 @@ function DimensionEditorModal({ sourceImage, onApply, onCancel }) {
return ( return (
<div <div
style={{ ...popupOverlayStyle, zIndex: 9999, padding: 20 }} style={{ ...popupOverlayStyle, zIndex: 9999, padding: 20 }}
onClick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
> >
<div style={{ ...popupSurfaceStyle, borderRadius: 8, display: 'flex', flexDirection: 'column', maxWidth: '98vw', maxHeight: '96vh', overflow: 'hidden', padding: 0 }}> <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 }}>
@@ -3436,7 +3440,6 @@ function PhotoEditorModal({
return ( return (
<div <div
style={{ ...popupOverlayStyle, zIndex: 9999, padding: 20 }} style={{ ...popupOverlayStyle, zIndex: 9999, padding: 20 }}
onClick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
> >
<div style={{ ...popupSurfaceStyle, borderRadius: 8, display: 'flex', flexDirection: 'column', maxWidth: '98vw', maxHeight: '96vh', overflow: 'hidden', padding: 0 }}> <div style={{ ...popupSurfaceStyle, borderRadius: 8, display: 'flex', flexDirection: 'column', maxWidth: '98vw', maxHeight: '96vh', overflow: 'hidden', padding: 0 }}>
{/* Header */} {/* Header */}
+3 -3
View File
@@ -32,7 +32,6 @@ function TeamCompanies() {
const [editingUserId, setEditingUserId] = useState(null); const [editingUserId, setEditingUserId] = useState(null);
const [editUserVal, setEditUserVal] = useState(''); const [editUserVal, setEditUserVal] = useState('');
const [deletingUserId, setDeletingUserId] = useState(null); const [deletingUserId, setDeletingUserId] = useState(null);
const [filterCompany, setFilterCompany] = useState('');
const [userSubTab, setUserSubTab] = useState(profileRole === 'external' ? 'external' : 'client'); const [userSubTab, setUserSubTab] = useState(profileRole === 'external' ? 'external' : 'client');
const { sortKey: coSortKey, sortDir: coSortDir, toggle: coToggle, sort: coSort } = useSortable('name'); const { sortKey: coSortKey, sortDir: coSortDir, toggle: coToggle, sort: coSort } = useSortable('name');
const { sortKey: clSortKey, sortDir: clSortDir, toggle: clToggle, sort: clSort } = useSortable('name'); const { sortKey: clSortKey, sortDir: clSortDir, toggle: clToggle, sort: clSort } = useSortable('name');
@@ -51,6 +50,9 @@ function TeamCompanies() {
setLoading(false); setLoading(false);
} }
// load() only setStates after awaiting its queries; the rule can't see past the
// call boundary, and load() is shared with the create/delete handlers below.
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { load(); }, []); useEffect(() => { load(); }, []);
const handleCreate = async (e) => { const handleCreate = async (e) => {
@@ -144,8 +146,6 @@ function TeamCompanies() {
console.error('Subcontractor folder sync failed:', folderError); console.error('Subcontractor folder sync failed:', folderError);
} }
} }
if (userForm.role === 'team' || userForm.role === 'external') {
}
setShowNewUser(false); setShowNewUser(false);
setUserForm({ name: '', email: '', password: '', company_id: '', role: 'client' }); setUserForm({ name: '', email: '', password: '', company_id: '', role: 'client' });
load(); load();
+1 -2
View File
@@ -6,7 +6,7 @@ import StatusBadge from '../components/StatusBadge';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { serviceTypes } from '../data/mockData'; import { serviceTypes } from '../data/mockData';
import { cleanupTaskStorage, deleteCompanyData } from '../lib/deleteHelpers'; import { deleteCompanyData } from '../lib/deleteHelpers';
import { logActivity } from '../lib/activityLog'; import { logActivity } from '../lib/activityLog';
import { ensureProjectFolder, renameCompanyFolder } from '../lib/folderSync'; import { ensureProjectFolder, renameCompanyFolder } from '../lib/folderSync';
@@ -70,7 +70,6 @@ export default function CompanyDetail() {
} }
useEffect(() => { useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
load(); load();
}, [id]); // eslint-disable-line react-hooks/exhaustive-deps }, [id]); // eslint-disable-line react-hooks/exhaustive-deps
+1 -2
View File
@@ -116,7 +116,7 @@ export default function ProfilePage() {
setViewedProfile(profile); setViewedProfile(profile);
setViewedCompanies([...names]); setViewedCompanies([...names]);
setPrimaryCompanyAddress(primaryAddress); setPrimaryCompanyAddress(primaryAddress);
} catch (err) { } catch {
if (!cancelled) setProfileError('Unable to load profile.'); if (!cancelled) setProfileError('Unable to load profile.');
} finally { } finally {
if (!cancelled) setLoadingProfile(false); if (!cancelled) setLoadingProfile(false);
@@ -642,7 +642,6 @@ export default function ProfilePage() {
{isSelfView && editOpen && ( {isSelfView && editOpen && (
<div <div
style={popupOverlayStyle} style={popupOverlayStyle}
onClick={() => { if (!savingProfile) setEditOpen(false); }}
> >
<div <div
style={{ style={{
+3 -22
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom'; import { useParams, Link } from 'react-router-dom';
import Layout from '../components/Layout'; import Layout from '../components/Layout';
import PageLoader from '../components/PageLoader'; import PageLoader from '../components/PageLoader';
import ProfileAvatar from '../components/ProfileAvatar'; import ProfileAvatar from '../components/ProfileAvatar';
@@ -33,11 +33,9 @@ const MODAL_IN = { fontSize: 13, color: 'var(--text-primary)', background: 'va
export default function ProjectDetailPage() { export default function ProjectDetailPage() {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate();
const { currentUser } = useAuth(); const { currentUser } = useAuth();
const isClient = currentUser?.role === 'client'; const isClient = currentUser?.role === 'client';
const isExternal = currentUser?.role === 'external';
const isTeam = currentUser?.role === 'team'; const isTeam = currentUser?.role === 'team';
const { refreshKey } = useLiveRefresh(['tasks', 'projects', 'project_members', 'profiles']); const { refreshKey } = useLiveRefresh(['tasks', 'projects', 'project_members', 'profiles']);
@@ -148,23 +146,6 @@ export default function ProjectDetailPage() {
setSavingName(false); setSavingName(false);
}; };
const handleDeleteProject = async () => {
if (!window.confirm(`Delete project "${project.name}"?`)) return;
const { data: { session } } = await supabase.auth.getSession();
const res = await fetch(`/api/delete-project?id=${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${session?.access_token}` } });
if (!res.ok) { const d = await res.json(); alert(d.error || 'Delete failed'); return; }
navigate(isClient ? '/tasks' : `/company/${company?.id}`);
};
const handleDeleteTask = async (taskId, e) => {
e.stopPropagation();
if (!window.confirm('Delete this task?')) return;
const { data: { session } } = await supabase.auth.getSession();
const res = await fetch(`/api/delete-task?id=${taskId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${session?.access_token}` } });
if (!res.ok) { const d = await res.json(); alert(d.error || 'Delete failed'); return; }
setTasks(prev => prev.filter(t => t.id !== taskId));
};
const handleAddMember = async () => { const handleAddMember = async () => {
setSavingMembers(true); setSavingMembers(true);
const currentIds = new Set(members.filter(m => m.profile?.role === 'external').map(m => m.profile_id)); const currentIds = new Set(members.filter(m => m.profile?.role === 'external').map(m => m.profile_id));
@@ -596,7 +577,7 @@ export default function ProjectDetailPage() {
</div> </div>
{showClientTask && isClient && ( {showClientTask && isClient && (
<div style={popupOverlayStyle} onClick={() => { setShowClientTask(false); setClientTaskKey(k => k + 1); setClientTaskError(''); }}> <div style={popupOverlayStyle}>
<div style={TASK_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}> <div style={TASK_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}>
<div style={{ ...TASK_MODAL_TITLE_STYLE, marginBottom: 14 }}>New Task</div> <div style={{ ...TASK_MODAL_TITLE_STYLE, marginBottom: 14 }}>New Task</div>
<RequestForm <RequestForm
@@ -618,7 +599,7 @@ export default function ProjectDetailPage() {
)} )}
{addingMember && isTeam && ( {addingMember && isTeam && (
<div style={popupOverlayStyle} onClick={() => { setAddingMember(false); setSelectedExts(new Set()); }}> <div style={popupOverlayStyle}>
<div style={{ ...TASK_MODAL_SHELL_STYLE, width: 'min(480px, 96vw)', display: 'flex', flexDirection: 'column', gap: 16, padding: 0 }} onClick={e => e.stopPropagation()}> <div style={{ ...TASK_MODAL_SHELL_STYLE, width: 'min(480px, 96vw)', display: 'flex', flexDirection: 'column', gap: 16, padding: 0 }} onClick={e => e.stopPropagation()}>
<div style={{ padding: '18px 21px 0' }}> <div style={{ padding: '18px 21px 0' }}>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 2 }}>Manage Subcontractors</div> <div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 2 }}>Manage Subcontractors</div>
+7 -8
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom'; import { useParams, Link } from 'react-router-dom';
import Layout from '../components/Layout'; import Layout from '../components/Layout';
import PageLoader from '../components/PageLoader'; import PageLoader from '../components/PageLoader';
@@ -13,7 +13,7 @@ import { useLiveRefresh } from '../hooks/useLiveRefresh';
import { useActionLock } from '../hooks/useActionLock'; import { useActionLock } from '../hooks/useActionLock';
import FileAttachment from '../components/FileAttachment'; import FileAttachment from '../components/FileAttachment';
import { sendTaskStatusUpdate } from '../lib/taskNotifications'; import { sendTaskStatusUpdate } from '../lib/taskNotifications';
import { encodeRejectedNote, parseRejectedNote, isReviewShadowSubmission, REVIEW_SHADOW_PREFIX, getVisibleTaskSubmissions, getTaskDerivedState } from '../lib/taskVersions'; import { encodeRejectedNote, parseRejectedNote, REVIEW_SHADOW_PREFIX, getVisibleTaskSubmissions, getTaskDerivedState } from '../lib/taskVersions';
import { getCurrentVersionForTask } from '../lib/taskDeadlines'; import { getCurrentVersionForTask } from '../lib/taskDeadlines';
import { getSubmissionDisplayName, mergeSubmissionDisplayNames } from '../lib/submissionDisplay'; import { getSubmissionDisplayName, mergeSubmissionDisplayNames } from '../lib/submissionDisplay';
@@ -261,7 +261,6 @@ export default function TaskDetail() {
const [revisionForm, setRevisionForm] = useState({ description: '', deadline: '' }); const [revisionForm, setRevisionForm] = useState({ description: '', deadline: '' });
const [revisionFiles, setRevisionFiles] = useState([]); const [revisionFiles, setRevisionFiles] = useState([]);
const [revisionSaving, setRevisionSaving] = useState(false); const [revisionSaving, setRevisionSaving] = useState(false);
const revisionFileRef = useRef(null);
const [rejectModal, setRejectModal] = useState(false); const [rejectModal, setRejectModal] = useState(false);
const [rejectNote, setRejectNote] = useState(''); const [rejectNote, setRejectNote] = useState('');
const [rejectSaving, setRejectSaving] = useState(false); const [rejectSaving, setRejectSaving] = useState(false);
@@ -976,7 +975,7 @@ export default function TaskDetail() {
</div> </div>
{comments.length === 0 {comments.length === 0
? <div className="card-empty-center">No comments</div> ? <div className="card-empty-center">No comments</div>
: commentRows.map((c, i) => { : commentRows.map((c) => {
const d = new Date(c.created_at); const d = new Date(c.created_at);
const dateStr = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); const dateStr = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
const timeStr = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); const timeStr = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
@@ -1035,7 +1034,7 @@ export default function TaskDetail() {
</div> </div>
{reviewModal && ( {reviewModal && (
<div style={popupOverlayStyle} onClick={() => { if (!reviewSaving) { setReviewModal(false); setReviewFiles([]); setReviewNotes(''); } }}> <div style={popupOverlayStyle}>
<div style={{ ...popupSurfaceStyle, width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 14 }} onClick={e => e.stopPropagation()}> <div style={{ ...popupSurfaceStyle, width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 14 }} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }}>Place in Review</div> <div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }}>Place in Review</div>
<div> <div>
@@ -1054,7 +1053,7 @@ export default function TaskDetail() {
)} )}
{revisionModal && ( {revisionModal && (
<div style={{ position: 'fixed', inset: 0, background: 'var(--overlay-scrim, rgba(0,0,0,0.58))', zIndex: 1200, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }} onClick={() => { if (!revisionSaving) { setRevisionModal(false); setRevisionForm({ description: '', deadline: '' }); setRevisionFiles([]); } }}> <div style={{ position: 'fixed', inset: 0, background: 'var(--overlay-scrim, rgba(0,0,0,0.58))', zIndex: 1200, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}>
<div className="card" style={{ ...CARD, width: '100%', maxWidth: 480, display: 'flex', flexDirection: 'column', gap: 18 }} onClick={e => e.stopPropagation()}> <div className="card" style={{ ...CARD, width: '100%', maxWidth: 480, display: 'flex', flexDirection: 'column', gap: 18 }} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 18, fontWeight: 500, color: 'var(--text-primary)', letterSpacing: 0.2, lineHeight: 1.1 }}>Request Revision</div> <div style={{ fontSize: 18, fontWeight: 500, color: 'var(--text-primary)', letterSpacing: 0.2, lineHeight: 1.1 }}>Request Revision</div>
<div className="form-group"> <div className="form-group">
@@ -1090,7 +1089,7 @@ export default function TaskDetail() {
)} )}
{rejectModal && ( {rejectModal && (
<div style={popupOverlayStyle} onClick={() => { if (!rejectSaving) { setRejectModal(false); setRejectNote(''); } }}> <div style={popupOverlayStyle}>
<div style={{ ...popupSurfaceStyle, width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}> <div style={{ ...popupSurfaceStyle, width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', lineHeight: 1.1 }}>Reject Task</div> <div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', lineHeight: 1.1 }}>Reject Task</div>
<div> <div>
@@ -1114,7 +1113,7 @@ export default function TaskDetail() {
)} )}
{amendModal && ( {amendModal && (
<div style={popupOverlayStyle} onClick={() => { setAmendModal(false); setAmendForm({ description: '' }); setAmendFiles([]); }}> <div style={popupOverlayStyle}>
<div style={{ ...popupSurfaceStyle, width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}> <div style={{ ...popupSurfaceStyle, width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', lineHeight: 1.1 }}>Amend Request</div> <div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', lineHeight: 1.1 }}>Amend Request</div>
<div> <div>
+7 -66
View File
@@ -79,50 +79,7 @@ function TasksStatsRow({ tasks = [] }) {
); );
} }
function TasksPageShell({ statsTasks = [], projects = [], onAddProject = null, children }) { function TasksPageShell({ statsTasks = [], children }) {
const [projSortKey, setProjSortKey] = useState('status');
const [projSortDir, setProjSortDir] = useState('asc');
const [projTab, setProjTab] = useState('active');
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' },
{ 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 projectTabCounts = {
all: projects.length,
active: projects.filter((p) => !completedStatuses.has((p?.status || '').toLowerCase())).length,
completed: projects.filter((p) => completedStatuses.has((p?.status || '').toLowerCase())).length,
};
const sortedProjects = [...visibleProjects].sort((a, b) => {
if (projSortKey === 'status') {
const ra = completedStatuses.has((a.status || '').toLowerCase()) ? 1 : 0;
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;
};
return ( return (
<div className="tasks-shell" style={{ minWidth: 0, flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}> <div className="tasks-shell" style={{ minWidth: 0, flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<TasksStatsRow tasks={statsTasks} /> <TasksStatsRow tasks={statsTasks} />
@@ -268,7 +225,7 @@ export default function RequestsPage() {
} }
loadTasksPage(); loadTasksPage();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [isTeam, isExternal, isClient, currentUser?.id, refreshKey]); // eslint-disable-line react-hooks/exhaustive-deps }, [isTeam, isExternal, isClient, currentUser?.id, refreshKey]);
const handleAddRequest = async (formData, files, existingProjects) => { const handleAddRequest = async (formData, files, existingProjects) => {
if (addSaving) return; if (addSaving) return;
@@ -293,7 +250,6 @@ export default function RequestsPage() {
console.error('Request folder sync failed:', folderError); console.error('Request folder sync failed:', folderError);
} }
if (files.length > 0) { if (files.length > 0) {
const taskCompany = companies?.find(c => c.id === formData.companyId);
setShowAddForm(false); setShowAddForm(false);
setUploadProgress({ active: true, label: 'Uploading files…', percent: 0 }); setUploadProgress({ active: true, label: 'Uploading files…', percent: 0 });
for (let fi = 0; fi < files.length; fi++) { for (let fi = 0; fi < files.length; fi++) {
@@ -459,7 +415,7 @@ export default function RequestsPage() {
return cos.slice().sort((a, b) => a.name.localeCompare(b.name)); return cos.slice().sort((a, b) => a.name.localeCompare(b.name));
} }
return companies.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 }, [isClient, companies, currentUser]);
// Normalize all tasks to a common row shape for unified table render // Normalize all tasks to a common row shape for unified table render
const allRows = useMemo(() => { const allRows = useMemo(() => {
@@ -487,7 +443,7 @@ export default function RequestsPage() {
submittedAt: derived.latestActivityAt, submittedAt: derived.latestActivityAt,
}; };
}).filter(Boolean); }).filter(Boolean);
}, [tasks, submissions, deliveries, isClient]); // eslint-disable-line react-hooks/exhaustive-deps }, [tasks, submissions, deliveries, isClient]);
const filteredRows = useMemo(() => { const filteredRows = useMemo(() => {
return allRows return allRows
@@ -587,13 +543,6 @@ export default function RequestsPage() {
return ''; 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 renderRow = (row) => { const renderRow = (row) => {
const project = projects.find(p => p.id === row.projectId); const project = projects.find(p => p.id === row.projectId);
@@ -639,21 +588,13 @@ export default function RequestsPage() {
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>; 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 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 ( return (
<Layout> <Layout>
<TasksPageShell <TasksPageShell statsTasks={filteredStatTasks}>
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 */} {/* Add project modal */}
{showAddProjectForm && ( {showAddProjectForm && (
<div style={popupOverlayStyle} onClick={() => { setShowAddProjectForm(false); setAddProjectForm({ name: '', companyId: '' }); setAddProjectError(''); }}> <div style={popupOverlayStyle}>
<div style={PROJECT_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}> <div style={PROJECT_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 14 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 14 }}>
<div style={TASK_MODAL_TITLE_STYLE}>Add Project</div> <div style={TASK_MODAL_TITLE_STYLE}>Add Project</div>
@@ -682,7 +623,7 @@ export default function RequestsPage() {
{/* Add task modal */} {/* Add task modal */}
{showAddForm && ( {showAddForm && (
<div style={popupOverlayStyle} onClick={() => { setShowAddForm(false); setAddFormKey(k => k + 1); setAddError(''); }}> <div style={popupOverlayStyle}>
<div style={TASK_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}> <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={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 14 }}>
<div style={TASK_MODAL_TITLE_STYLE}>{isTeam ? 'Add Task' : 'New Task'}</div> <div style={TASK_MODAL_TITLE_STYLE}>{isTeam ? 'Add Task' : 'New Task'}</div>
+3 -4
View File
@@ -10,9 +10,11 @@ import { supabase } from '../../lib/supabase';
import { generateInvoicePDF, generateReceiptPDF } from '../../lib/invoice'; import { generateInvoicePDF, generateReceiptPDF } from '../../lib/invoice';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/AuthContext';
import { useSortable } from '../../hooks/useSortable'; import { useSortable } from '../../hooks/useSortable';
import InvoiceDetailPopup, { POPUP_FIELD_LABEL } from '../../components/InvoiceDetailPopup'; import InvoiceDetailPopup from '../../components/InvoiceDetailPopup';
import { POPUP_FIELD_LABEL } from '../../lib/popupStyles';
import InvoicePopupTable from '../../components/InvoicePopupTable'; import InvoicePopupTable from '../../components/InvoicePopupTable';
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' }; const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
const invoiceStatusLabel = (status) => { const invoiceStatusLabel = (status) => {
if (status === 'sent') return 'Invoiced'; if (status === 'sent') return 'Invoiced';
@@ -103,8 +105,6 @@ function ClientInvoiceModal({ invoice, onClose }) {
title={invoice.invoice_number} title={invoice.invoice_number}
subtitle={invoice.bill_to || company?.name} subtitle={invoice.bill_to || company?.name}
headerRight={<StatusBadge status={statusColor[invoice.status] || 'not_started'} label={`${invoiceStatusLabel(invoice.status)}${isOverdue ? ' · Overdue' : ''}`} />} headerRight={<StatusBadge status={statusColor[invoice.status] || 'not_started'} label={`${invoiceStatusLabel(invoice.status)}${isOverdue ? ' · Overdue' : ''}`} />}
onClose={onClose}
blockClose={Boolean(downloading)}
metaContent={<> metaContent={<>
<div><div style={F}>Invoice Date</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{invoice.invoice_date ? new Date(invoice.invoice_date).toLocaleDateString() : '—'}</div></div> <div><div style={F}>Invoice Date</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{invoice.invoice_date ? new Date(invoice.invoice_date).toLocaleDateString() : '—'}</div></div>
<div><div style={F}>Due Date</div><div style={{ fontSize: 13, color: isOverdue ? 'var(--danger)' : 'var(--text-primary)' }}>{invoice.due_date ? new Date(invoice.due_date).toLocaleDateString() : '—'}</div></div> <div><div style={F}>Due Date</div><div style={{ fontSize: 13, color: isOverdue ? 'var(--danger)' : 'var(--text-primary)' }}>{invoice.due_date ? new Date(invoice.due_date).toLocaleDateString() : '—'}</div></div>
@@ -167,7 +167,6 @@ export default function MyInvoices() {
return () => document.removeEventListener('mousedown', onDocClick); return () => document.removeEventListener('mousedown', onDocClick);
}, [filterMenuOpen]); }, [filterMenuOpen]);
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const invoiceYears = [...new Set(invoices.map(i => i.invoice_date?.slice(0, 4)).filter(Boolean))].sort((a, b) => b.localeCompare(a)); const invoiceYears = [...new Set(invoices.map(i => i.invoice_date?.slice(0, 4)).filter(Boolean))].sort((a, b) => b.localeCompare(a));
const availableCompanyOptions = (currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : [])) const availableCompanyOptions = (currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : []))
.filter(company => company?.id && invoices.some(inv => inv.company_id === company.id)) .filter(company => company?.id && invoices.some(inv => inv.company_id === company.id))
+3 -30
View File
@@ -9,8 +9,8 @@ import { readPageCache, writePageCache } from '../../lib/pageCache';
import { useSortable } from '../../hooks/useSortable'; import { useSortable } from '../../hooks/useSortable';
import { isCompletedVersionEligible } from '../../lib/invoiceVersionRules'; import { isCompletedVersionEligible } from '../../lib/invoiceVersionRules';
import SubcontractorInvoiceForm from '../../components/SubcontractorInvoiceForm'; import SubcontractorInvoiceForm from '../../components/SubcontractorInvoiceForm';
import { popupOverlayStyle, popupSurfaceStyle } from '../../lib/popupStyles'; import { popupOverlayStyle, popupSurfaceStyle, POPUP_FIELD_LABEL } from '../../lib/popupStyles';
import InvoiceDetailPopup, { POPUP_FIELD_LABEL } from '../../components/InvoiceDetailPopup'; import InvoiceDetailPopup from '../../components/InvoiceDetailPopup';
import InvoicePopupTable from '../../components/InvoicePopupTable'; import InvoicePopupTable from '../../components/InvoicePopupTable';
import { generateSubcontractorPOPDF } from '../../lib/invoice'; import { generateSubcontractorPOPDF } from '../../lib/invoice';
@@ -60,10 +60,6 @@ function parseItemVersionNumber(item) {
return match ? Number(match[1]) : 0; return match ? Number(match[1]) : 0;
} }
function itemVersionLabel(item) {
return `R${String(parseItemVersionNumber(item)).padStart(2, '0')}`;
}
function itemWorkLabel(item) { function itemWorkLabel(item) {
return parseItemVersionNumber(item) > 0 ? 'Revision' : 'New'; return parseItemVersionNumber(item) > 0 ? 'Revision' : 'New';
} }
@@ -72,24 +68,6 @@ function cleanItemDescription(item) {
return String(item?.description || '—').replace(/\s*[-]\s*R\d{2}\b/i, '').trim() || '—'; return String(item?.description || '—').replace(/\s*[-]\s*R\d{2}\b/i, '').trim() || '—';
} }
async function fetchTaskTypeMap(taskIds) {
const uniqueTaskIds = [...new Set((taskIds || []).filter(Boolean))];
if (uniqueTaskIds.length === 0) return {};
const { data, error } = await supabase
.from('tasks')
.select('id, title, submissions(service_type, type)')
.in('id', uniqueTaskIds);
if (error) throw error;
const next = {};
for (const task of (data || [])) {
const submissions = asArray(task.submissions);
const initial = submissions.find((submission) => submission?.type === 'initial' && submission?.service_type);
const fallback = submissions.find((submission) => submission?.service_type);
next[task.id] = initial?.service_type || fallback?.service_type || task.title || 'Other';
}
return next;
}
function buildPDFArgs(invoice, profile, total, statusOverride) { function buildPDFArgs(invoice, profile, total, statusOverride) {
return { return {
po_number: invoice.invoice_number, po_number: invoice.invoice_number,
@@ -125,7 +103,6 @@ export default function MyInvoices() {
const [viewingInvoice, setViewingInvoice] = useState(null); const [viewingInvoice, setViewingInvoice] = useState(null);
const [detailLoading, setDetailLoading] = useState(false); const [detailLoading, setDetailLoading] = useState(false);
const [detailError, setDetailError] = useState(''); const [detailError, setDetailError] = useState('');
const [detailTaskTypeMap, setDetailTaskTypeMap] = useState({});
const [submittingInvoice, setSubmittingInvoice] = useState(false); const [submittingInvoice, setSubmittingInvoice] = useState(false);
const [downloadingInvoice, setDownloadingInvoice] = useState(false); const [downloadingInvoice, setDownloadingInvoice] = useState(false);
const [downloadingReceipt, setDownloadingReceipt] = useState(false); const [downloadingReceipt, setDownloadingReceipt] = useState(false);
@@ -249,8 +226,6 @@ export default function MyInvoices() {
.eq('id', invoiceId) .eq('id', invoiceId)
.single(); .single();
if (err || !data) throw err || new Error('Invoice not found.'); if (err || !data) throw err || new Error('Invoice not found.');
const taskTypeMap = await fetchTaskTypeMap(asArray(data.items).map((item) => item.task_id));
setDetailTaskTypeMap(taskTypeMap);
setViewingInvoice(data); setViewingInvoice(data);
} catch (err) { } catch (err) {
setDetailError(err?.message || 'Failed to load invoice.'); setDetailError(err?.message || 'Failed to load invoice.');
@@ -428,7 +403,7 @@ export default function MyInvoices() {
</div> </div>
{showInvoiceForm && ( {showInvoiceForm && (
<div style={popupOverlayStyle} onClick={() => setShowInvoiceForm(false)}> <div style={popupOverlayStyle}>
<div <div
style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
@@ -469,8 +444,6 @@ export default function MyInvoices() {
title={inv?.invoice_number || 'Subcontractor Invoice'} title={inv?.invoice_number || 'Subcontractor Invoice'}
subtitle={`${currentUser?.name || 'Subcontractor'}${currentUser?.email ? ` · ${currentUser.email}` : ''}`} subtitle={`${currentUser?.name || 'Subcontractor'}${currentUser?.email ? ` · ${currentUser.email}` : ''}`}
headerRight={inv ? <StatusBadge status={STATUS_BADGE[inv.status] || 'not_started'} label={invoiceStatus} /> : null} headerRight={inv ? <StatusBadge status={STATUS_BADGE[inv.status] || 'not_started'} label={invoiceStatus} /> : null}
onClose={() => { if (!busy) { setViewingInvoice(null); setDetailError(''); } }}
blockClose={busy}
loading={detailLoading && !inv} loading={detailLoading && !inv}
metaContent={inv ? <> metaContent={inv ? <>
<div><div style={F}>Created</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{inv.created_at ? new Date(inv.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div></div> <div><div style={F}>Created</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{inv.created_at ? new Date(inv.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div></div>
+3 -16
View File
@@ -18,19 +18,6 @@ import { isReviewShadowDescription } from '../../lib/taskVersions';
// ─── Helpers ────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────
const ICON_TONES = [
{ bg: 'color-mix(in srgb, var(--accent) 15%, transparent)', color: 'var(--accent)' },
{ bg: 'color-mix(in srgb, var(--positive) 15%, transparent)', color: 'var(--positive)' },
{ bg: 'color-mix(in srgb, var(--info) 15%, transparent)', color: 'var(--info)' },
{ bg: 'color-mix(in srgb, var(--violet) 15%, transparent)', color: 'var(--violet)' },
];
function iconTone(key) {
let h = 0;
for (let i = 0; i < (key || '').length; i++) h = (h * 31 + key.charCodeAt(i)) % ICON_TONES.length;
return ICON_TONES[h];
}
function fmtMoney(n) { function fmtMoney(n) {
if (Math.abs(n) >= 1000000) return `$${(n / 1000000).toFixed(2)}M`; if (Math.abs(n) >= 1000000) return `$${(n / 1000000).toFixed(2)}M`;
if (Math.abs(n) >= 1000) return `$${(n / 1000).toFixed(1)}k`; if (Math.abs(n) >= 1000) return `$${(n / 1000).toFixed(1)}k`;
@@ -486,7 +473,7 @@ function HotItemsCard({ submissions, tasks, isClient = false, isExternal = false
); );
} }
function TeamPerformanceCard({ tasks, profiles, deliveries, submissions, cardHeight = null }) { function TeamPerformanceCard({ profiles, deliveries, submissions, cardHeight = null }) {
const navigate = useNavigate(); const navigate = useNavigate();
const profileMap = useMemo(() => { const profileMap = useMemo(() => {
const m = new Map(); const m = new Map();
@@ -547,7 +534,7 @@ function TeamPerformanceCard({ tasks, profiles, deliveries, submissions, cardHei
items.push({ task: e.task, kind: e.version === 0 ? 'new' : 'rev', monthKey, person }); items.push({ task: e.task, kind: e.version === 0 ? 'new' : 'rev', monthKey, person });
}); });
return items; return items;
}, [submissions, delByVer, delByTask, delAtVer, delAtTask]); // eslint-disable-line react-hooks/exhaustive-deps }, [submissions, delByVer, delByTask, delAtVer, delAtTask]);
const monthsWithData = useMemo(() => new Set(eligibleItems.map(i => i.monthKey).filter(Boolean)), [eligibleItems]); const monthsWithData = useMemo(() => new Set(eligibleItems.map(i => i.monthKey).filter(Boolean)), [eligibleItems]);
const allMonthOpts = useMemo(() => { const allMonthOpts = useMemo(() => {
@@ -869,7 +856,7 @@ export default function TeamDashboard() {
<div className="dash-row-trio"> <div className="dash-row-trio">
<ActivityFeed events={activityEvents} /> <ActivityFeed events={activityEvents} />
<TasksInProgressCard tasks={inProgressTasks} /> <TasksInProgressCard tasks={inProgressTasks} />
<TeamPerformanceCard tasks={tasks} profiles={allProfiles} deliveries={perfDeliveries} submissions={perfSubmissions} /> <TeamPerformanceCard profiles={allProfiles} deliveries={perfDeliveries} submissions={perfSubmissions} />
</div> </div>
</Layout> </Layout>
); );
+2 -3
View File
@@ -10,7 +10,8 @@ import { generateInvoicePDF, generateReceiptPDF } from '../../lib/invoice';
import { blobToEmailAttachment, sendEmail } from '../../lib/email'; import { blobToEmailAttachment, sendEmail } from '../../lib/email';
import { withTimeout } from '../../lib/withTimeout'; import { withTimeout } from '../../lib/withTimeout';
import { useSortable } from '../../hooks/useSortable'; import { useSortable } from '../../hooks/useSortable';
import InvoiceDetailPopup, { POPUP_FIELD_LABEL } from '../../components/InvoiceDetailPopup'; import InvoiceDetailPopup from '../../components/InvoiceDetailPopup';
import { POPUP_FIELD_LABEL } from '../../lib/popupStyles';
import InvoicePopupTable from '../../components/InvoicePopupTable'; import InvoicePopupTable from '../../components/InvoicePopupTable';
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' }; const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
@@ -403,8 +404,6 @@ export function TeamInvoiceDetailPanel({
label={`${invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—'}${isOverdue ? ' · Overdue' : ''}`} label={`${invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—'}${isOverdue ? ' · Overdue' : ''}`}
/> />
) : null} ) : null}
onClose={onClose}
blockClose={saving || Boolean(generating)}
loading={loading} loading={loading}
metaContent={invoice ? <> metaContent={invoice ? <>
<div> <div>
+9 -231
View File
@@ -11,36 +11,20 @@ import { useActionLock } from '../../hooks/useActionLock';
import { supabase } from '../../lib/supabase'; import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/AuthContext';
import { readPageCache, writePageCache } from '../../lib/pageCache'; import { readPageCache, writePageCache } from '../../lib/pageCache';
import { exportCPAPackage, generateSubcontractorPOPDF, generateInvoicePDF } from '../../lib/invoice'; import { generateSubcontractorPOPDF, generateInvoicePDF } from '../../lib/invoice';
import { withTimeout } from '../../lib/withTimeout'; import { withTimeout } from '../../lib/withTimeout';
import LoadingButton from '../../components/LoadingButton'; import LoadingButton from '../../components/LoadingButton';
import { blobToEmailAttachment, sendEmail } from '../../lib/email'; import { blobToEmailAttachment, sendEmail } from '../../lib/email';
import { popupOverlayStyle, popupSurfaceStyle } from '../../lib/popupStyles'; import { popupOverlayStyle, popupSurfaceStyle, POPUP_FIELD_LABEL } from '../../lib/popupStyles';
import FileAttachment from '../../components/FileAttachment'; import FileAttachment from '../../components/FileAttachment';
import { isReviewShadowDescription, pickInitialServiceType } from '../../lib/taskVersions'; import { isReviewShadowDescription, pickInitialServiceType } from '../../lib/taskVersions';
import { getRevisionChargeQuantity, isCompletedVersionEligible, isInitialVersionEligible } from '../../lib/invoiceVersionRules'; import { getRevisionChargeQuantity, isCompletedVersionEligible, isInitialVersionEligible } from '../../lib/invoiceVersionRules';
import { TeamInvoiceDetailPanel } from './TeamInvoiceDetail'; import { TeamInvoiceDetailPanel } from './TeamInvoiceDetail';
import InvoiceDetailPopup, { POPUP_FIELD_LABEL } from '../../components/InvoiceDetailPopup'; import InvoiceDetailPopup from '../../components/InvoiceDetailPopup';
import InvoicePopupTable from '../../components/InvoicePopupTable'; import InvoicePopupTable from '../../components/InvoicePopupTable';
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' };
const poStatusColor = {
draft: 'not_started',
sent: 'in_progress',
approved: 'client_approved',
ready_to_pay: 'in_progress',
paid: 'client_approved',
cancelled: 'needs_revision',
};
const poStatusLabel = {
draft: 'Draft',
sent: 'Sent',
approved: 'Approved',
ready_to_pay: 'Ready to Pay',
paid: 'Paid',
cancelled: 'Cancelled',
};
const invoiceStatusBadgeLabel = (status) => { const invoiceStatusBadgeLabel = (status) => {
if (status === 'sent') return 'Invoiced'; if (status === 'sent') return 'Invoiced';
return status ? status.charAt(0).toUpperCase() + status.slice(1) : '—'; return status ? status.charAt(0).toUpperCase() + status.slice(1) : '—';
@@ -126,10 +110,6 @@ function parseSubInvoiceItemVersionNumber(item) {
return match ? Number(match[1]) : 0; return match ? Number(match[1]) : 0;
} }
function subInvoiceItemVersionLabel(item) {
return `R${String(parseSubInvoiceItemVersionNumber(item)).padStart(2, '0')}`;
}
function subInvoiceItemWorkLabel(item) { function subInvoiceItemWorkLabel(item) {
return parseSubInvoiceItemVersionNumber(item) > 0 ? 'Revision' : 'New'; return parseSubInvoiceItemVersionNumber(item) > 0 ? 'Revision' : 'New';
} }
@@ -138,30 +118,6 @@ function cleanSubInvoiceItemDescription(item) {
return String(item?.description || '—').replace(/\s*[-]\s*R\d{2}\b/i, '').trim() || '—'; return String(item?.description || '—').replace(/\s*[-]\s*R\d{2}\b/i, '').trim() || '—';
} }
function asArray(value) {
if (Array.isArray(value)) return value;
if (value == null) return [];
return typeof value === 'object' ? [value] : [];
}
async function fetchSubInvoiceTaskTypeMap(taskIds) {
const uniqueTaskIds = [...new Set((taskIds || []).filter(Boolean))];
if (uniqueTaskIds.length === 0) return {};
const { data, error } = await supabase
.from('tasks')
.select('id, title, submissions(service_type, type)')
.in('id', uniqueTaskIds);
if (error) throw error;
const next = {};
for (const task of (data || [])) {
const submissions = asArray(task.submissions);
const initial = submissions.find((submission) => submission?.type === 'initial' && submission?.service_type);
const fallback = submissions.find((submission) => submission?.service_type);
next[task.id] = initial?.service_type || fallback?.service_type || task.title || 'Other';
}
return next;
}
function CurrencyInput({ value, onChange, placeholder = '0.00', required = false, min = '0', step = '0.01' }) { function CurrencyInput({ value, onChange, placeholder = '0.00', required = false, min = '0', step = '0.01' }) {
return ( return (
<div style={FINANCE_MODAL_AMOUNT_FIELD_STYLE}> <div style={FINANCE_MODAL_AMOUNT_FIELD_STYLE}>
@@ -243,9 +199,6 @@ export default function Invoices() {
const { sortKey: invSortKey, sortDir: invSortDir, toggle: invToggle, sort: invSort } = useSortable('invoice_date', 'desc'); const { sortKey: invSortKey, sortDir: invSortDir, toggle: invToggle, sort: invSort } = useSortable('invoice_date', 'desc');
const { sortKey: expSortKey, sortDir: expSortDir, toggle: expToggle, sort: expSort } = useSortable('date', 'desc'); const { sortKey: expSortKey, sortDir: expSortDir, toggle: expToggle, sort: expSort } = useSortable('date', 'desc');
const { sortKey: subSortKey, sortDir: subSortDir, toggle: subToggle, sort: subSort } = useSortable('submitted_at'); const { sortKey: subSortKey, sortDir: subSortDir, toggle: subToggle, sort: subSort } = useSortable('submitted_at');
const { sortKey: ovExpKey, sortDir: ovExpDir, toggle: ovExpToggle } = useSortable('date');
const { sortKey: ovSubKey, sortDir: ovSubDir, toggle: ovSubToggle } = useSortable('date');
const { sortKey: ovInvKey, sortDir: ovInvDir, toggle: ovInvToggle } = useSortable('invoice_date');
const { sortKey: subPopupKey, sortDir: subPopupDir, toggle: subPopupToggle, sort: subPopupSort } = useSortable('description'); const { sortKey: subPopupKey, sortDir: subPopupDir, toggle: subPopupToggle, sort: subPopupSort } = useSortable('description');
const [filterCompany, setFilterCompany] = useState(''); const [filterCompany, setFilterCompany] = useState('');
const initMonth = () => { const n = new Date(); return { month: n.getMonth(), year: n.getFullYear() }; }; const initMonth = () => { const n = new Date(); return { month: n.getMonth(), year: n.getFullYear() }; };
@@ -253,7 +206,6 @@ export default function Invoices() {
const [ovMonth2, setOvMonth2] = useState(initMonth); const [ovMonth2, setOvMonth2] = useState(initMonth);
const [ovMonth3, setOvMonth3] = useState(initMonth); const [ovMonth3, setOvMonth3] = useState(initMonth);
const [exportYear, setExportYear] = useState(new Date().getFullYear()); const [exportYear, setExportYear] = useState(new Date().getFullYear());
const [exporting, setExporting] = useState(false);
const [expenses, setExpenses] = useState([]); const [expenses, setExpenses] = useState([]);
const [expensesLoading, setExpensesLoading] = useState(true); const [expensesLoading, setExpensesLoading] = useState(true);
@@ -272,18 +224,12 @@ export default function Invoices() {
const [subWhoFilter, setSubWhoFilter] = useState('all'); const [subWhoFilter, setSubWhoFilter] = useState('all');
const [subStatusFilter, setSubStatusFilter] = useState('all'); const [subStatusFilter, setSubStatusFilter] = useState('all');
const [subYearFilter, setSubYearFilter] = useState('all'); const [subYearFilter, setSubYearFilter] = useState('all');
const [subcontractorPOs, setSubcontractorPOs] = useState([]);
const [subcontractorLoading, setSubcontractorLoading] = useState(true);
const [subcontractorError, setSubcontractorError] = useState('');
const [selectedSubcontractorPOId, setSelectedSubcontractorPOId] = useState('');
const [subInvoices, setSubInvoices] = useState([]); const [subInvoices, setSubInvoices] = useState([]);
const [subInvoicesLoading, setSubInvoicesLoading] = useState(true); const [subInvoicesLoading, setSubInvoicesLoading] = useState(true);
const [subInvoicesError, setSubInvoicesError] = useState(''); const [subInvoicesError, setSubInvoicesError] = useState('');
const [markingPaid, setMarkingPaid] = useState(''); const [markingPaid, setMarkingPaid] = useState('');
const [viewingSubInvoice, setViewingSubInvoice] = useState(null); const [viewingSubInvoice, setViewingSubInvoice] = useState(null);
const [viewingSubInvoiceTaskTypeMap, setViewingSubInvoiceTaskTypeMap] = useState({});
const [viewingInvoice, setViewingInvoice] = useState(null); const [viewingInvoice, setViewingInvoice] = useState(null);
const [expandedSubInvoiceId, setExpandedSubInvoiceId] = useState(null);
// ── New Invoice form ────────────────────────────────────────────────────── // ── New Invoice form ──────────────────────────────────────────────────────
const [showInvoiceForm, setShowInvoiceForm] = useState(false); const [showInvoiceForm, setShowInvoiceForm] = useState(false);
@@ -301,30 +247,12 @@ export default function Invoices() {
const guard = useActionLock(); const guard = useActionLock();
const [invLoadingTasks, setInvLoadingTasks] = useState(false); const [invLoadingTasks, setInvLoadingTasks] = useState(false);
const invDragItem = useRef(null); const invDragItem = useRef(null);
const pageLoading = loading || expensesLoading || subcontractorLoading || subInvoicesLoading; const pageLoading = loading || expensesLoading || subInvoicesLoading;
useEffect(() => { useEffect(() => {
supabase.from('companies').select('id, name, contact_email').order('name').then(({ data }) => setInvCompanies(data || [])); supabase.from('companies').select('id, name, contact_email').order('name').then(({ data }) => setInvCompanies(data || []));
}, []); }, []);
useEffect(() => {
let cancelled = false;
async function loadSubInvoiceTaskTypes() {
if (!viewingSubInvoice?.items?.length) {
setViewingSubInvoiceTaskTypeMap({});
return;
}
try {
const map = await fetchSubInvoiceTaskTypeMap(asArray(viewingSubInvoice.items).map((item) => item.task_id));
if (!cancelled) setViewingSubInvoiceTaskTypeMap(map);
} catch {
if (!cancelled) setViewingSubInvoiceTaskTypeMap({});
}
}
loadSubInvoiceTaskTypes();
return () => { cancelled = true; };
}, [viewingSubInvoice]);
useEffect(() => { useEffect(() => {
if (!invCompanyId) { setInvUnbilledTasks([]); setInvUnbilledRevisions([]); setInvPriceList([]); setInvItems([invNewItem()]); setInvBillTo(''); setInvEmail(''); setInvRecipients([]); return; } if (!invCompanyId) { setInvUnbilledTasks([]); setInvUnbilledRevisions([]); setInvPriceList([]); setInvItems([invNewItem()]); setInvBillTo(''); setInvEmail(''); setInvRecipients([]); return; }
const co = invCompanies.find(c => c.id === invCompanyId); const co = invCompanies.find(c => c.id === invCompanyId);
@@ -456,13 +384,7 @@ export default function Invoices() {
useEffect(() => { useEffect(() => {
async function loadExpenses() { async function loadExpenses() {
const [{ data, error }, { data: purchaseOrders, error: purchaseOrdersError }] = await Promise.all([ const { data, error } = await supabase.from('expenses').select('*').order('date', { ascending: false });
supabase.from('expenses').select('*').order('date', { ascending: false }),
supabase
.from('subcontractor_payments')
.select('*, profile:profiles!subcontractor_payments_profile_id_fkey(id, name, email), project:projects(id, name, company:companies(name)), items:subcontractor_po_items(*, task:tasks(id, title))')
.order('date', { ascending: false }),
]);
if (error) { if (error) {
console.error('Failed to load expenses:', error); console.error('Failed to load expenses:', error);
setExpensesError(error.message || 'Failed to load expenses.'); setExpensesError(error.message || 'Failed to load expenses.');
@@ -471,16 +393,7 @@ export default function Invoices() {
setExpenses(data || []); setExpenses(data || []);
setExpensesError(''); setExpensesError('');
} }
if (purchaseOrdersError) {
console.error('Failed to load subcontractor POs:', purchaseOrdersError);
setSubcontractorError(purchaseOrdersError.message || 'Failed to load subcontractor POs.');
setSubcontractorPOs([]);
} else {
setSubcontractorPOs(purchaseOrders || []);
setSubcontractorError('');
}
setExpensesLoading(false); setExpensesLoading(false);
setSubcontractorLoading(false);
} }
loadExpenses(); loadExpenses();
}, []); }, []);
@@ -579,37 +492,6 @@ export default function Invoices() {
win.location.href = data.signedUrl; win.location.href = data.signedUrl;
}; };
const updateSubcontractorPO = async (po, updates, errorLabel = 'Failed to update PO') => {
const { data, error } = await supabase
.from('subcontractor_payments')
.update(updates)
.eq('id', po.id)
.select('*, profile:profiles!subcontractor_payments_profile_id_fkey(id, name, email), project:projects(id, name, company:companies(name)), items:subcontractor_po_items(*, task:tasks(id, title))')
.single();
if (error) {
alert(`${errorLabel}: ${error.message}`);
return null;
}
if (data) {
setSubcontractorPOs(prev => prev.map(row => row.id === po.id ? data : row));
setSelectedSubcontractorPOId(current => current === po.id ? data.id : current);
}
return data;
};
const handleSendSubcontractorPO = async (po) => {
await updateSubcontractorPO(po, { status: 'sent', sent_at: new Date().toISOString() }, 'Failed to send PO');
};
const handleReadyToPaySubcontractorPO = (po) => {
updateSubcontractorPO(po, { status: 'ready_to_pay' }, 'Failed to mark ready to pay');
};
const handleMarkSubcontractorPaid = async (po) => {
const paidAt = new Date().toISOString().slice(0, 10);
updateSubcontractorPO(po, { status: 'paid', paid_at: paidAt }, 'Failed to mark as paid');
};
const handleMarkSubInvoicePaid = async (invoice) => { const handleMarkSubInvoicePaid = async (invoice) => {
setMarkingPaid(invoice.id); setMarkingPaid(invoice.id);
try { try {
@@ -710,68 +592,6 @@ export default function Invoices() {
} }
}; };
const handleReopenSubcontractorPO = async (po) => {
updateSubcontractorPO(po, { status: 'draft', paid_at: null, cancelled_at: null }, 'Failed to reopen PO');
};
const handleCancelSubcontractorPO = async (po) => {
if (!window.confirm(`Cancel ${po.po_number || 'this PO'}?`)) return;
updateSubcontractorPO(po, { status: 'cancelled', cancelled_at: new Date().toISOString() }, 'Failed to cancel PO');
};
const handleDeleteSubcontractorPO = async (id) => {
if (!window.confirm('Delete this subcontractor PO?')) return;
await supabase.from('subcontractor_payments').delete().eq('id', id);
setSubcontractorPOs(prev => prev.filter(po => po.id !== id));
setSelectedSubcontractorPOId(current => current === id ? '' : current);
};
const handleDownloadSubcontractorPO = async (po) => {
try {
await generateSubcontractorPOPDF(po);
} catch (error) {
console.error('Failed to download subcontractor PO:', error);
alert(`Failed to download PO: ${error.message || 'unknown error'}`);
}
};
const handleCPAExport = async () => {
setExporting(true);
try {
const yearInvoices = invoices.filter(inv =>
inv.status === 'paid' && new Date(inv.invoice_date).getFullYear() === exportYear
);
const yearExpenses = expenses.filter(exp =>
new Date(exp.date).getFullYear() === exportYear
);
const yearSubcontractorExpenses = subcontractorPOs
.filter(po => po.status === 'paid' && new Date(po.paid_at || po.date).getFullYear() === exportYear)
.map(po => ({
date: po.paid_at || po.date,
category: 'Contractor',
description: `Subcontractor: ${po.profile?.name || 'External'}${po.items?.length ? po.items.map(item => item.description).join('; ') : po.description}`,
amount: po.amount,
notes: [po.po_number, po.project?.name, po.notes || po.profile?.email || ''].filter(Boolean).join(' · '),
}));
const exportExpenses = [...yearExpenses, ...yearSubcontractorExpenses];
if (!yearInvoices.length && !exportExpenses.length) {
alert(`No data found for ${exportYear}.`);
return;
}
const { data: allItems } = yearInvoices.length ? await supabase
.from('invoice_items')
.select('invoice_id, description')
.in('invoice_id', yearInvoices.map(i => i.id)) : { data: [] };
const itemsByInvoice = (allItems || []).reduce((acc, item) => {
(acc[item.invoice_id] = acc[item.invoice_id] || []).push(item);
return acc;
}, {});
await exportCPAPackage(yearInvoices, itemsByInvoice, exportExpenses, exportYear);
} finally {
setExporting(false);
}
};
const paidYears = [...new Set( const paidYears = [...new Set(
invoices.filter(i => i.status === 'paid').map(i => new Date(i.invoice_date).getFullYear()) invoices.filter(i => i.status === 'paid').map(i => new Date(i.invoice_date).getFullYear())
)].sort((a, b) => b - a); )].sort((a, b) => b - a);
@@ -783,45 +603,12 @@ export default function Invoices() {
return true; return true;
}); });
const totals = {
all: invoices.reduce((s, i) => s + Number(i.total), 0),
draft: invoices.filter(i => i.status === 'draft').reduce((s, i) => s + Number(i.total), 0),
sent: invoices.filter(i => i.status === 'sent').reduce((s, i) => s + Number(i.total), 0),
paid: invoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total), 0),
netReceived: invoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total) - Number(i.stripe_fee || 0), 0),
};
const expenseYears = [...new Set(expenses.map(e => e.date?.slice(0, 4)).filter(Boolean))].sort((a, b) => b - a); const expenseYears = [...new Set(expenses.map(e => e.date?.slice(0, 4)).filter(Boolean))].sort((a, b) => b - a);
const filteredExpenses = expenses.filter(e => { const filteredExpenses = expenses.filter(e => {
if (expYearFilter !== 'all' && e.date?.slice(0, 4) !== expYearFilter) return false; if (expYearFilter !== 'all' && e.date?.slice(0, 4) !== expYearFilter) return false;
if (expenseFilter !== 'all' && e.category !== expenseFilter) return false; if (expenseFilter !== 'all' && e.category !== expenseFilter) return false;
return true; return true;
}); });
const paidSubcontractorPOs = subcontractorPOs.filter(po => po.status === 'paid');
const payableSubcontractorPOs = subcontractorPOs.filter(po => ['approved', 'ready_to_pay'].includes(po.status));
const selectedSubcontractorPO = subcontractorPOs.find(po => po.id === selectedSubcontractorPOId);
const subInvoiceItemTotal = (inv) => (inv.items || []).reduce((a, x) => a + Number(x.unit_price || 0) * Number(x.quantity || 1), 0);
const paidSubInvoices = subInvoices.filter(i => i.status === 'paid');
const totalPaidSubInvoices = paidSubInvoices.reduce((s, i) => s + subInvoiceItemTotal(i), 0);
const totalPaidSubcontractors = paidSubcontractorPOs.reduce((s, po) => s + Number(po.amount), 0) + totalPaidSubInvoices;
const totalPayableSubcontractorPOs = payableSubcontractorPOs.reduce((s, po) => s + Number(po.amount), 0);
const totalPayableSubInvoices = subInvoices.filter(i => i.status === 'submitted').reduce((s, i) => s + subInvoiceItemTotal(i), 0);
const totalPayableSubcontractors = totalPayableSubcontractorPOs + totalPayableSubInvoices;
const payableSubcontractorCount = payableSubcontractorPOs.length + subInvoices.filter(i => i.status === 'submitted').length;
const totalExpenses = filteredExpenses.reduce((s, e) => s + Number(e.amount), 0);
const currentYear = new Date().getFullYear();
const yearExpenses = expenses.filter(e => new Date(e.date).getFullYear() === currentYear);
const currentYearExpenseTotal = yearExpenses.reduce((s, e) => s + Number(e.amount), 0);
const currentYearPaidSubcontractorPOs = paidSubcontractorPOs
.filter(po => new Date(po.paid_at || po.date).getFullYear() === currentYear)
.reduce((s, po) => s + Number(po.amount), 0);
const currentYearPaidSubInvoices = paidSubInvoices
.filter(i => new Date(i.paid_at).getFullYear() === currentYear)
.reduce((s, i) => s + subInvoiceItemTotal(i), 0);
const currentYearTotalExpenses = currentYearExpenseTotal + currentYearPaidSubcontractorPOs + currentYearPaidSubInvoices;
const revenue = totals.paid;
const profit = totals.netReceived - expenses.reduce((s, e) => s + Number(e.amount), 0) - totalPaidSubcontractors;
const chartYear = exportYear; const chartYear = exportYear;
const chartData = useMemo(() => MONTHS.map((month, mi) => { const chartData = useMemo(() => MONTHS.map((month, mi) => {
const paidInvs = invoices.filter(inv => inv.status === 'paid' && new Date(inv.invoice_date).getFullYear() === chartYear && new Date(inv.invoice_date).getMonth() === mi); const paidInvs = invoices.filter(inv => inv.status === 'paid' && new Date(inv.invoice_date).getFullYear() === chartYear && new Date(inv.invoice_date).getMonth() === mi);
@@ -937,13 +724,6 @@ export default function Invoices() {
const CARD = { background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' }; const CARD = { background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' };
const TH = { fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.6, padding: '0 0 12px 0', border: 'none', background: 'transparent', textAlign: 'left' }; const TH = { fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.6, padding: '0 0 12px 0', border: 'none', background: 'transparent', textAlign: 'left' };
const TD = { fontSize: 13, color: 'var(--text-primary)', padding: '5px 0', border: 'none', background: 'transparent', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }; const TD = { fontSize: 13, color: 'var(--text-primary)', padding: '5px 0', border: 'none', background: 'transparent', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
const sortRows = (arr, key, dir, getter) => [...arr].sort((a, b) => {
const av = getter(a, key), bv = getter(b, key);
if (av < bv) return dir === 'asc' ? -1 : 1;
if (av > bv) return dir === 'asc' ? 1 : -1;
return 0;
});
const fmtDate = d => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '—';
const fmtAmt = v => `$${Number(v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; const fmtAmt = v => `$${Number(v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
const now = new Date(); const now = new Date();
@@ -1508,7 +1288,7 @@ export default function Invoices() {
{ id: 'invoices', label: 'INVOICES' }, { id: 'invoices', label: 'INVOICES' },
{ id: 'expenses', label: 'EXPENSES' }, { id: 'expenses', label: 'EXPENSES' },
{ id: 'sub-invoices', label: 'SUBCONTRACTOR INVOICES' }, { id: 'sub-invoices', label: 'SUBCONTRACTOR INVOICES' },
].map((t, i, arr) => ( ].map((t) => (
<button <button
key={t.id} key={t.id}
type="button" type="button"
@@ -1761,8 +1541,6 @@ export default function Invoices() {
title={invoice.invoice_number || 'Subcontractor Invoice'} title={invoice.invoice_number || 'Subcontractor Invoice'}
subtitle={`${invoice.profile?.name || 'External'}${invoice.profile?.email ? ` · ${invoice.profile.email}` : ''}`} subtitle={`${invoice.profile?.name || 'External'}${invoice.profile?.email ? ` · ${invoice.profile.email}` : ''}`}
headerRight={<StatusBadge status={subInvoiceStatusColor[invoice.status] || 'not_started'} label={invoiceStatus} />} headerRight={<StatusBadge status={subInvoiceStatusColor[invoice.status] || 'not_started'} label={invoiceStatus} />}
onClose={() => { if (!markingPaid) setViewingSubInvoice(null); }}
blockClose={Boolean(markingPaid)}
metaContent={<> metaContent={<>
<div><div style={F}>Submitted</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{invoice.submitted_at ? new Date(invoice.submitted_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div></div> <div><div style={F}>Submitted</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{invoice.submitted_at ? new Date(invoice.submitted_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div></div>
<div><div style={F}>Paid</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{invoice.paid_at ? new Date(invoice.paid_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div></div> <div><div style={F}>Paid</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{invoice.paid_at ? new Date(invoice.paid_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div></div>
@@ -1819,7 +1597,7 @@ export default function Invoices() {
newExpense.removeReceipt === true newExpense.removeReceipt === true
); );
return ( return (
<div style={popupOverlayStyle} onClick={() => { setViewingExpense(null); setExpenseDetailEditing(false); }}> <div style={popupOverlayStyle}>
<div style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}> <div style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
{/* Main content row */} {/* Main content row */}
<div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0 }}> <div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0 }}>
@@ -1914,7 +1692,7 @@ export default function Invoices() {
const LABEL = { ...FIELD_LABEL_STYLE, marginBottom: 3 }; const LABEL = { ...FIELD_LABEL_STYLE, marginBottom: 3 };
const INPUT = FINANCE_MODAL_INPUT_STYLE; const INPUT = FINANCE_MODAL_INPUT_STYLE;
return ( return (
<div style={popupOverlayStyle} onClick={cancelExpenseEdit}> <div style={popupOverlayStyle}>
<div style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}> <div style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
<form onSubmit={handleAddExpense} style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, gap: 0 }}> <form onSubmit={handleAddExpense} style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, gap: 0 }}>
{/* Main content row */} {/* Main content row */}
@@ -1970,7 +1748,7 @@ export default function Invoices() {
const LABEL = { ...FIELD_LABEL_STYLE, marginBottom: 3 }; const LABEL = { ...FIELD_LABEL_STYLE, marginBottom: 3 };
const INPUT = FINANCE_MODAL_INPUT_STYLE; const INPUT = FINANCE_MODAL_INPUT_STYLE;
return ( return (
<div style={popupOverlayStyle} onClick={() => { if (!invSaving) invClose(); }}> <div style={popupOverlayStyle}>
<div style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}> <div style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0 }}> <div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0 }}>
+35 -4
View File
@@ -34,9 +34,16 @@ function subBillingStatusFromInvoices(items = []) {
function billingLabel(status) { function billingLabel(status) {
if (status === 'paid') return 'Paid'; if (status === 'paid') return 'Paid';
if (status === 'invoiced') return 'Invoiced'; if (status === 'invoiced') return 'Invoiced';
if (status === 'not_applicable') return 'N/A';
return 'Not Invoiced'; return 'Not Invoiced';
} }
function asArray(value) {
if (Array.isArray(value)) return value;
if (value == null) return [];
return typeof value === 'object' ? [value] : [];
}
function versionTypeLabel(versionNumber) { function versionTypeLabel(versionNumber) {
return Number(versionNumber || 0) === 0 ? 'New' : 'Revision'; return Number(versionNumber || 0) === 0 ? 'New' : 'Revision';
} }
@@ -85,6 +92,7 @@ export default function TeamReports() {
{ data: submissionRows, error: submissionsError }, { data: submissionRows, error: submissionsError },
{ data: invoiceItemRows, error: invoiceItemsError }, { data: invoiceItemRows, error: invoiceItemsError },
{ data: subInvoiceRows, error: subInvoicesError }, { data: subInvoiceRows, error: subInvoicesError },
{ data: profileRows, error: profilesError },
] = await Promise.all([ ] = await Promise.all([
supabase supabase
.from('tasks') .from('tasks')
@@ -92,7 +100,7 @@ export default function TeamReports() {
.order('title'), .order('title'),
supabase supabase
.from('submissions') .from('submissions')
.select('id, task_id, type, version_number, submitted_at, invoiced, description') .select('id, task_id, type, version_number, submitted_at, invoiced, description, delivery:deliveries(sent_by)')
.in('type', ['initial', 'revision']) .in('type', ['initial', 'revision'])
.order('submitted_at', { ascending: true }), .order('submitted_at', { ascending: true }),
supabase supabase
@@ -101,16 +109,28 @@ export default function TeamReports() {
supabase supabase
.from('subcontractor_invoices') .from('subcontractor_invoices')
.select('status, items:subcontractor_invoice_items(task_id, version_number, description)') .select('status, items:subcontractor_invoice_items(task_id, version_number, description)')
.in('status', ['submitted', 'approved', 'paid']) .in('status', ['submitted', 'approved', 'paid']),
supabase
.from('profiles')
.select('name, role'),
]); ]);
if (tasksError) throw tasksError; if (tasksError) throw tasksError;
if (submissionsError) throw submissionsError; if (submissionsError) throw submissionsError;
if (invoiceItemsError) throw invoiceItemsError; if (invoiceItemsError) throw invoiceItemsError;
if (subInvoicesError) throw subInvoicesError; if (subInvoicesError) throw subInvoicesError;
if (profilesError) throw profilesError;
if (cancelled) return; if (cancelled) return;
const taskMap = new Map((taskRows || []).map((task) => [task.id, task])); // Sub billing only applies to work an external subcontractor delivered.
// A team member's own delivery is billed to the client directly and is
// never sub-invoiced, no matter who started the task's earlier versions.
const roleByName = new Map(
(profileRows || [])
.filter((profile) => profile.name)
.map((profile) => [profile.name.trim().toLowerCase(), profile.role])
);
const companiesMap = new Map(); const companiesMap = new Map();
const projectsMap = new Map(); const projectsMap = new Map();
(taskRows || []).forEach((task) => { (taskRows || []).forEach((task) => {
@@ -137,6 +157,12 @@ export default function TeamReports() {
if (!entry.first_submitted_at || (submission.submitted_at && submission.submitted_at < entry.first_submitted_at)) { if (!entry.first_submitted_at || (submission.submitted_at && submission.submitted_at < entry.first_submitted_at)) {
entry.first_submitted_at = submission.submitted_at; entry.first_submitted_at = submission.submitted_at;
} }
// Duplicate submissions for the same task+version can exist (double-submits);
// take the deliverer from whichever one actually has a delivery logged.
if (!entry.delivered_by) {
const deliverer = asArray(submission.delivery)[0]?.sent_by;
if (deliverer) entry.delivered_by = deliverer;
}
} }
const invoiceItemGroups = new Map(); const invoiceItemGroups = new Map();
@@ -190,7 +216,12 @@ export default function TeamReports() {
const key = `${task.id}:${Number(versionEntry.version_number || 0)}`; const key = `${task.id}:${Number(versionEntry.version_number || 0)}`;
const invoiceItems = invoiceItemGroups.get(key) || []; const invoiceItems = invoiceItemGroups.get(key) || [];
const billingStatus = billingStatusFromInvoices(invoiceItems); const billingStatus = billingStatusFromInvoices(invoiceItems);
const subBillingStatus = subBillingStatusFromInvoices(subBillingGroups.get(key) || []); const delivererRole = versionEntry.delivered_by
? roleByName.get(versionEntry.delivered_by.trim().toLowerCase()) || null
: null;
const subBillingStatus = delivererRole === 'external'
? subBillingStatusFromInvoices(subBillingGroups.get(key) || [])
: 'not_applicable';
rows.push({ rows.push({
key, key,
company_id: company?.id || '', company_id: company?.id || '',