diff --git a/src/lib/invoiceVersionRules.js b/src/lib/invoiceVersionRules.js index 0d6d35e..fbbc7a5 100644 --- a/src/lib/invoiceVersionRules.js +++ b/src/lib/invoiceVersionRules.js @@ -16,3 +16,38 @@ export function getRevisionChargeQuantity(versionNumber) { const version = Number(versionNumber || 0); return version >= 2 ? 1 : 0; } + +// Parses version number from an invoice item description like "Project • Task – R01" +export function parseVersionFromItemDescription(description = '') { + const match = String(description).match(/[–\-]\s*R(\d{2})\b/i); + return match ? Number(match[1]) : 0; +} + +// Builds a Map<"taskId:version", "sent"|"paid"> from invoice_items rows +// (each row must have invoice.status joined) +export function buildInvoiceStatusByKey(invoiceItems = []) { + const map = new Map(); + for (const item of invoiceItems) { + const status = item.invoice?.status; + if (!status || !['sent', 'paid'].includes(status)) continue; + const version = parseVersionFromItemDescription(item.description); + const key = `${item.task_id}:${version}`; + const existing = map.get(key); + // paid beats sent + if (!existing || (status === 'paid' && existing !== 'paid')) { + map.set(key, status); + } + } + return map; +} + +// Per-version status: invoice state takes priority over task work state +export function deriveVersionStatus(task, version, invoiceStatusByKey) { + const invoiceStatus = invoiceStatusByKey?.get(`${task.id}:${version}`); + if (invoiceStatus === 'paid') return 'paid'; + if (invoiceStatus === 'sent') return 'invoiced'; + // Past version — work moved on, implicitly approved + if (version < (task.current_version ?? 0)) return 'client_approved'; + // Current version — use task work status + return task.status; +} diff --git a/src/pages/ProjectDetail.jsx b/src/pages/ProjectDetail.jsx index 9ae2923..f79077d 100644 --- a/src/pages/ProjectDetail.jsx +++ b/src/pages/ProjectDetail.jsx @@ -17,6 +17,7 @@ import { fmtShortDate } from '../lib/dates'; import { popupOverlayStyle } from '../lib/popupStyles'; import { useLiveRefresh } from '../hooks/useLiveRefresh'; import { getTaskDerivedState } from '../lib/taskVersions'; +import { buildInvoiceStatusByKey, deriveVersionStatus } from '../lib/invoiceVersionRules'; import { TASK_TABLE_TH_STYLE, TASK_TABLE_TD_BASE, @@ -49,6 +50,7 @@ export default function ProjectDetailPage() { const [loading, setLoading] = useState(true); const [submissions, setSubmissions] = useState([]); const [deliveries, setDeliveries] = useState([]); + const [invoiceItems, setInvoiceItems] = useState([]); const [activeTab, setActiveTab] = useState('all'); const [editingName, setEditingName] = useState(false); @@ -111,6 +113,14 @@ export default function ProjectDetailPage() { const { data: act, error: actErr } = await supabase.from('activity_log').select('id, created_at, actor_name, action, task_title').eq('project_id', id).order('created_at', { ascending: false }).limit(50); if (actErr) console.error('activity_log fetch:', actErr); setActivityLog((act || []).filter(e => ['task_started', 'task_on_hold', 'task_approved'].includes(e.action))); + if (isTeam && taskIds.length > 0) { + const { data: invItems } = await supabase + .from('invoice_items') + .select('task_id, description, invoice:invoices(status)') + .in('task_id', taskIds) + .not('task_id', 'is', null); + setInvoiceItems(invItems || []); + } if (isTeam) { const { data: ext } = await supabase.from('profiles').select('id, name, avatar_url, email').eq('role', 'external').order('name'); setExtProfs(ext || []); @@ -229,15 +239,21 @@ export default function ProjectDetailPage() { if (loading) return ; if (!project) return

Project not found.

; - const rows = tasks.map(task => { + const invoiceStatusByKey = buildInvoiceStatusByKey(invoiceItems); + const rows = tasks.flatMap(task => { const derived = getTaskDerivedState(task, submissions, deliveries); - return { - id: task.id, title: task.title, status: task.status, - version: derived.currentVersion, + const versionSet = new Set(derived.taskSubs.map(s => Number(s.version_number ?? 0))); + versionSet.add(derived.currentVersion); + return [...versionSet].sort((a, b) => a - b).map(v => ({ + id: task.id, + rowKey: `${task.id}:${v}`, + title: task.title, + status: deriveVersionStatus(task, v, invoiceStatusByKey), + version: v, assignedName: task.assigned_name || null, assigneeAvatar: task.assignee?.avatar_url || null, assignedTo: task.assigned_to || null, serviceType: derived.serviceType, deadline: derived.deadline, isHot: derived.isHot, submittedAt: derived.latestActivityAt ? new Date(derived.latestActivityAt).toISOString() : (task.submitted_at || ''), - }; + })); }); const sortedRows = sort(rows, (r, key) => { @@ -303,7 +319,7 @@ export default function ProjectDetailPage() { {(() => { - const approved = tasks.filter(t => ['client_approved','invoiced','paid'].includes(t.status)).length; + const approved = tasks.filter(t => t.status === 'client_approved').length; const pct = tasks.length > 0 ? Math.round((approved / tasks.length) * 100) : 0; return (
@@ -403,7 +419,7 @@ export default function ProjectDetailPage() { {visibleRows.map(row => { const avatarStyle = { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, borderRadius: '50%', overflow: 'hidden', verticalAlign: 'middle', flexShrink: 0 }; return ( - + {`R${String(row.version).padStart(2, '0')}`} diff --git a/src/pages/Tasks.jsx b/src/pages/Tasks.jsx index 07254f7..d144948 100644 --- a/src/pages/Tasks.jsx +++ b/src/pages/Tasks.jsx @@ -21,6 +21,7 @@ import { popupOverlayStyle } from '../lib/popupStyles'; import { useLiveRefresh } from '../hooks/useLiveRefresh'; import { resolveScopedWorkIds } from '../lib/workScope'; import { mergeSubmissionDisplayNames } from '../lib/submissionDisplay'; +import { buildInvoiceStatusByKey, deriveVersionStatus } from '../lib/invoiceVersionRules'; import { TASK_TABLE_TH_STYLE, TASK_TABLE_TD_BASE, @@ -206,6 +207,7 @@ export default function RequestsPage() { const [tasks, setTasks] = useState(() => teamCached?.tasks || extCached?.tasks || []); const [submissions, setSubmissions] = useState(() => teamCached?.submissions || extCached?.submissions || []); const [deliveries, setDeliveries] = useState([]); + const [invoiceItems, setInvoiceItems] = useState([]); const [companies, setCompanies] = useState(() => teamCached?.companies || []); const [loading, setLoading] = useState(() => { if (isTeam) return !teamCached; @@ -321,6 +323,17 @@ export default function RequestsPage() { setDeliveries(deliveryRows); setCompanies(co || []); + // Fetch invoice items for per-version status derivation (team only) + if (isTeam && (t || []).length > 0) { + const taskIdsList = (t || []).map(tk => tk.id); + const { data: invItems } = await supabase + .from('invoice_items') + .select('task_id, description, invoice:invoices(status)') + .in('task_id', taskIdsList) + .not('task_id', 'is', null); + setInvoiceItems(invItems || []); + } + if (isTeam) { writePageCache('team_requests', { submissions: hydratedSubs, tasks: t || [], projects: p || [], companies: co || [] }); } else if (isExternal) { @@ -529,13 +542,15 @@ export default function RequestsPage() { return companies.slice().sort((a, b) => (a.name || '').localeCompare(b.name || '')); }, [isClient, companies, currentUser]); // eslint-disable-line react-hooks/exhaustive-deps - // Normalize all tasks to a common row shape for unified table render + const invoiceStatusByKey = useMemo(() => buildInvoiceStatusByKey(invoiceItems), [invoiceItems]); + + // Normalize tasks → one row per version for independent tracking/billing const allRows = useMemo(() => { if (isClient) { return tasks.map(task => { const derived = getTaskDerivedState(task, submissions, deliveries); return { - id: task.id, title: task.title, status: task.status, projectId: task.project_id, + id: task.id, rowKey: task.id, title: task.title, status: task.status, projectId: task.project_id, serviceType: derived.serviceType, deadline: derived.initialSubmission?.deadline || derived.deadline || null, version: derived.currentVersion, isHot: false, assignedName: task.assigned_name || null, assigneeAvatar: task.assignee?.avatar_url || null, assignedTo: task.assigned_to || null, @@ -543,19 +558,27 @@ export default function RequestsPage() { }; }); } - return tasks.map(task => { + return tasks.flatMap(task => { const derived = getTaskDerivedState(task, submissions, deliveries); - if (derived.visibleTaskSubs.length === 0 || !derived.deadlineSource) return null; - return { - id: task.id, title: task.title, status: task.status, projectId: task.project_id, - serviceType: derived.serviceType, deadline: derived.deadline, - version: derived.currentVersion, + if (derived.visibleTaskSubs.length === 0 || !derived.deadlineSource) return []; + // One row per version that has a submission, always include current version + const versionSet = new Set(derived.taskSubs.map(s => Number(s.version_number ?? 0))); + versionSet.add(derived.currentVersion); + return [...versionSet].sort((a, b) => a - b).map(v => ({ + id: task.id, + rowKey: `${task.id}:${v}`, + title: task.title, + status: deriveVersionStatus(task, v, invoiceStatusByKey), + projectId: task.project_id, + serviceType: derived.serviceType, + deadline: derived.deadline, + version: v, isHot: derived.isHot, assignedName: task.assigned_name || null, assigneeAvatar: task.assignee?.avatar_url || null, assignedTo: task.assigned_to || null, submittedAt: derived.latestActivityAt, - }; - }).filter(Boolean); - }, [tasks, submissions, deliveries, isClient]); // eslint-disable-line react-hooks/exhaustive-deps + })); + }); + }, [tasks, submissions, deliveries, invoiceStatusByKey, isClient]); // eslint-disable-line react-hooks/exhaustive-deps const filteredRows = useMemo(() => { return allRows @@ -620,7 +643,7 @@ export default function RequestsPage() { const renderRow = (row) => { const project = projects.find(p => p.id === row.projectId); return ( - + {`R${String(row.version).padStart(2, '0')}`} diff --git a/src/pages/team/TeamCreateInvoice.jsx b/src/pages/team/TeamCreateInvoice.jsx index 5c86a59..6a8f97a 100644 --- a/src/pages/team/TeamCreateInvoice.jsx +++ b/src/pages/team/TeamCreateInvoice.jsx @@ -235,7 +235,7 @@ export default function CreateInvoice() { const taskIds = [...new Set(validItems.filter(i => i.task_id && !i.submission_id).map(i => i.task_id))]; if (taskIds.length > 0) { - const { error: taskError } = await supabase.from('tasks').update({ invoiced: true, status: 'invoiced' }).in('id', taskIds).eq('status', 'client_approved'); + const { error: taskError } = await supabase.from('tasks').update({ invoiced: true }).in('id', taskIds); if (taskError) throw taskError; } diff --git a/src/pages/team/TeamInvoiceDetail.jsx b/src/pages/team/TeamInvoiceDetail.jsx index b8eb15b..2e4e5e4 100644 --- a/src/pages/team/TeamInvoiceDetail.jsx +++ b/src/pages/team/TeamInvoiceDetail.jsx @@ -81,13 +81,8 @@ export function TeamInvoiceDetailPanel({ // Sync task statuses along invoice lifecycle const { data: freshItems } = await supabase.from('invoice_items').select('task_id, submission_id').eq('invoice_id', invoiceId); const taskIds = (freshItems || []).filter(i => i.task_id && !i.submission_id).map(i => i.task_id); - if (taskIds.length > 0) { - const newTaskStatus = status === 'paid' ? 'paid' : status === 'sent' ? 'invoiced' : 'client_approved'; - // Guard: only update tasks still at the expected prior status so tasks - // that have moved on to a new revision cycle aren't overwritten. - const guardStatus = status === 'paid' ? 'invoiced' : status === 'sent' ? 'client_approved' : 'paid'; - await supabase.from('tasks').update({ status: newTaskStatus }).in('id', taskIds).eq('status', guardStatus); - } + // Task work status is not synced to invoice lifecycle. + // Per-version status is derived from invoice_items at display time. if (status === 'paid') { try { const contactEmail = invoice.invoice_email || await getDefaultInvoiceEmail(invoice.company_id, company); diff --git a/src/pages/team/TeamInvoices.jsx b/src/pages/team/TeamInvoices.jsx index c75c688..5b5fdc4 100644 --- a/src/pages/team/TeamInvoices.jsx +++ b/src/pages/team/TeamInvoices.jsx @@ -410,7 +410,7 @@ export default function Invoices() { const validItems = invItems.filter(i => i.description); if (validItems.length > 0) await supabase.from('invoice_items').insert(validItems.map(it => ({ invoice_id: invoice.id, task_id: it.task_id || null, submission_id: it.submission_id || null, description: it.description, quantity: Number(it.quantity) || 1, unit_price: Number(it.unit_price) || 0 }))); const taskIds = [...new Set(validItems.filter(i => i.task_id && !i.submission_id).map(i => i.task_id))]; - if (taskIds.length > 0) await supabase.from('tasks').update({ invoiced: true, status: 'invoiced' }).in('id', taskIds).eq('status', 'client_approved'); + if (taskIds.length > 0) await supabase.from('tasks').update({ invoiced: true }).in('id', taskIds); const subIds = [...new Set(validItems.filter(i => i.submission_id).map(i => i.submission_id))]; if (subIds.length > 0) await supabase.from('submissions').update({ invoiced: true }).in('id', subIds); if (status === 'sent') {