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') {