feat: per-version R## tracking and billing separation

Each revision (R00, R01, R02) is now an independent trackable unit:

invoiceVersionRules: add deriveVersionStatus, buildInvoiceStatusByKey,
  parseVersionFromItemDescription for per-version status derivation

Tasks.jsx + ProjectDetail.jsx:
  - Fetch invoice_items joined with invoice.status (team only)
  - allRows/rows now flatMap per version (one row per R##)
  - Status derived from: invoice_items > past version > task.status
  - R00 paid shows as paid; R01 in_progress shows separately
  - tr keys use rowKey (taskId:version) to avoid duplicate key warnings

Invoice lifecycle (TeamInvoiceDetail, TeamInvoices, TeamCreateInvoice):
  - Remove task.status sync on invoice paid/sent/created
  - Keep invoiced:true flag for double-billing prevention
  - Task work status (not_started → client_approved) stays work-flow only

ProjectDetail completion bar uses task.status === client_approved only
  (no longer polluted by invoiced/paid status from invoice sync)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Krao Hasanee
2026-06-09 22:36:36 -05:00
parent da0e5d5c39
commit 5b2d5b65a4
6 changed files with 97 additions and 28 deletions
+35
View File
@@ -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;
}
+23 -7
View File
@@ -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 <Layout><PageLoader /></Layout>;
if (!project) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Project not found.</p></Layout>;
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() {
</div>
{(() => {
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 (
<div style={{ display: 'flex', alignItems: 'center', gap: 30, flexWrap: 'wrap' }}>
@@ -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 (
<tr key={row.id}>
<tr key={row.rowKey}>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>
<Link to={`/tasks/${row.id}`} className="table-link">{`R${String(row.version).padStart(2, '0')}`}</Link>
</td>
+35 -12
View File
@@ -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 (
<tr key={row.id}>
<tr key={row.rowKey}>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>
{`R${String(row.version).padStart(2, '0')}`}
</td>
+1 -1
View File
@@ -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;
}
+2 -7
View File
@@ -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);
+1 -1
View File
@@ -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') {