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:
@@ -16,3 +16,38 @@ export function getRevisionChargeQuantity(versionNumber) {
|
|||||||
const version = Number(versionNumber || 0);
|
const version = Number(versionNumber || 0);
|
||||||
return version >= 2 ? 1 : 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { fmtShortDate } from '../lib/dates';
|
|||||||
import { popupOverlayStyle } from '../lib/popupStyles';
|
import { popupOverlayStyle } from '../lib/popupStyles';
|
||||||
import { useLiveRefresh } from '../hooks/useLiveRefresh';
|
import { useLiveRefresh } from '../hooks/useLiveRefresh';
|
||||||
import { getTaskDerivedState } from '../lib/taskVersions';
|
import { getTaskDerivedState } from '../lib/taskVersions';
|
||||||
|
import { buildInvoiceStatusByKey, deriveVersionStatus } from '../lib/invoiceVersionRules';
|
||||||
import {
|
import {
|
||||||
TASK_TABLE_TH_STYLE,
|
TASK_TABLE_TH_STYLE,
|
||||||
TASK_TABLE_TD_BASE,
|
TASK_TABLE_TD_BASE,
|
||||||
@@ -49,6 +50,7 @@ export default function ProjectDetailPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [submissions, setSubmissions] = useState([]);
|
const [submissions, setSubmissions] = useState([]);
|
||||||
const [deliveries, setDeliveries] = useState([]);
|
const [deliveries, setDeliveries] = useState([]);
|
||||||
|
const [invoiceItems, setInvoiceItems] = useState([]);
|
||||||
const [activeTab, setActiveTab] = useState('all');
|
const [activeTab, setActiveTab] = useState('all');
|
||||||
|
|
||||||
const [editingName, setEditingName] = useState(false);
|
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);
|
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);
|
if (actErr) console.error('activity_log fetch:', actErr);
|
||||||
setActivityLog((act || []).filter(e => ['task_started', 'task_on_hold', 'task_approved'].includes(e.action)));
|
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) {
|
if (isTeam) {
|
||||||
const { data: ext } = await supabase.from('profiles').select('id, name, avatar_url, email').eq('role', 'external').order('name');
|
const { data: ext } = await supabase.from('profiles').select('id, name, avatar_url, email').eq('role', 'external').order('name');
|
||||||
setExtProfs(ext || []);
|
setExtProfs(ext || []);
|
||||||
@@ -229,15 +239,21 @@ export default function ProjectDetailPage() {
|
|||||||
if (loading) return <Layout><PageLoader /></Layout>;
|
if (loading) return <Layout><PageLoader /></Layout>;
|
||||||
if (!project) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Project not found.</p></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);
|
const derived = getTaskDerivedState(task, submissions, deliveries);
|
||||||
return {
|
const versionSet = new Set(derived.taskSubs.map(s => Number(s.version_number ?? 0)));
|
||||||
id: task.id, title: task.title, status: task.status,
|
versionSet.add(derived.currentVersion);
|
||||||
version: 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,
|
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,
|
serviceType: derived.serviceType, deadline: derived.deadline, isHot: derived.isHot,
|
||||||
submittedAt: derived.latestActivityAt ? new Date(derived.latestActivityAt).toISOString() : (task.submitted_at || ''),
|
submittedAt: derived.latestActivityAt ? new Date(derived.latestActivityAt).toISOString() : (task.submitted_at || ''),
|
||||||
};
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
const sortedRows = sort(rows, (r, key) => {
|
const sortedRows = sort(rows, (r, key) => {
|
||||||
@@ -303,7 +319,7 @@ export default function ProjectDetailPage() {
|
|||||||
</div>
|
</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;
|
const pct = tasks.length > 0 ? Math.round((approved / tasks.length) * 100) : 0;
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 30, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 30, flexWrap: 'wrap' }}>
|
||||||
@@ -403,7 +419,7 @@ export default function ProjectDetailPage() {
|
|||||||
{visibleRows.map(row => {
|
{visibleRows.map(row => {
|
||||||
const avatarStyle = { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, borderRadius: '50%', overflow: 'hidden', verticalAlign: 'middle', flexShrink: 0 };
|
const avatarStyle = { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, borderRadius: '50%', overflow: 'hidden', verticalAlign: 'middle', flexShrink: 0 };
|
||||||
return (
|
return (
|
||||||
<tr key={row.id}>
|
<tr key={row.rowKey}>
|
||||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>
|
<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>
|
<Link to={`/tasks/${row.id}`} className="table-link">{`R${String(row.version).padStart(2, '0')}`}</Link>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
+35
-12
@@ -21,6 +21,7 @@ import { popupOverlayStyle } from '../lib/popupStyles';
|
|||||||
import { useLiveRefresh } from '../hooks/useLiveRefresh';
|
import { useLiveRefresh } from '../hooks/useLiveRefresh';
|
||||||
import { resolveScopedWorkIds } from '../lib/workScope';
|
import { resolveScopedWorkIds } from '../lib/workScope';
|
||||||
import { mergeSubmissionDisplayNames } from '../lib/submissionDisplay';
|
import { mergeSubmissionDisplayNames } from '../lib/submissionDisplay';
|
||||||
|
import { buildInvoiceStatusByKey, deriveVersionStatus } from '../lib/invoiceVersionRules';
|
||||||
import {
|
import {
|
||||||
TASK_TABLE_TH_STYLE,
|
TASK_TABLE_TH_STYLE,
|
||||||
TASK_TABLE_TD_BASE,
|
TASK_TABLE_TD_BASE,
|
||||||
@@ -206,6 +207,7 @@ export default function RequestsPage() {
|
|||||||
const [tasks, setTasks] = useState(() => teamCached?.tasks || extCached?.tasks || []);
|
const [tasks, setTasks] = useState(() => teamCached?.tasks || extCached?.tasks || []);
|
||||||
const [submissions, setSubmissions] = useState(() => teamCached?.submissions || extCached?.submissions || []);
|
const [submissions, setSubmissions] = useState(() => teamCached?.submissions || extCached?.submissions || []);
|
||||||
const [deliveries, setDeliveries] = useState([]);
|
const [deliveries, setDeliveries] = useState([]);
|
||||||
|
const [invoiceItems, setInvoiceItems] = useState([]);
|
||||||
const [companies, setCompanies] = useState(() => teamCached?.companies || []);
|
const [companies, setCompanies] = useState(() => teamCached?.companies || []);
|
||||||
const [loading, setLoading] = useState(() => {
|
const [loading, setLoading] = useState(() => {
|
||||||
if (isTeam) return !teamCached;
|
if (isTeam) return !teamCached;
|
||||||
@@ -321,6 +323,17 @@ export default function RequestsPage() {
|
|||||||
setDeliveries(deliveryRows);
|
setDeliveries(deliveryRows);
|
||||||
setCompanies(co || []);
|
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) {
|
if (isTeam) {
|
||||||
writePageCache('team_requests', { submissions: hydratedSubs, tasks: t || [], projects: p || [], companies: co || [] });
|
writePageCache('team_requests', { submissions: hydratedSubs, tasks: t || [], projects: p || [], companies: co || [] });
|
||||||
} else if (isExternal) {
|
} else if (isExternal) {
|
||||||
@@ -529,13 +542,15 @@ export default function RequestsPage() {
|
|||||||
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]); // 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(() => {
|
const allRows = useMemo(() => {
|
||||||
if (isClient) {
|
if (isClient) {
|
||||||
return tasks.map(task => {
|
return tasks.map(task => {
|
||||||
const derived = getTaskDerivedState(task, submissions, deliveries);
|
const derived = getTaskDerivedState(task, submissions, deliveries);
|
||||||
return {
|
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,
|
serviceType: derived.serviceType, deadline: derived.initialSubmission?.deadline || derived.deadline || null,
|
||||||
version: derived.currentVersion, isHot: false,
|
version: derived.currentVersion, isHot: false,
|
||||||
assignedName: task.assigned_name || null, assigneeAvatar: task.assignee?.avatar_url || null, assignedTo: task.assigned_to || null,
|
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);
|
const derived = getTaskDerivedState(task, submissions, deliveries);
|
||||||
if (derived.visibleTaskSubs.length === 0 || !derived.deadlineSource) return null;
|
if (derived.visibleTaskSubs.length === 0 || !derived.deadlineSource) return [];
|
||||||
return {
|
// One row per version that has a submission, always include current version
|
||||||
id: task.id, title: task.title, status: task.status, projectId: task.project_id,
|
const versionSet = new Set(derived.taskSubs.map(s => Number(s.version_number ?? 0)));
|
||||||
serviceType: derived.serviceType, deadline: derived.deadline,
|
versionSet.add(derived.currentVersion);
|
||||||
version: 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,
|
isHot: derived.isHot,
|
||||||
assignedName: task.assigned_name || null, assigneeAvatar: task.assignee?.avatar_url || null, assignedTo: task.assigned_to || null,
|
assignedName: task.assigned_name || null, assigneeAvatar: task.assignee?.avatar_url || null, assignedTo: task.assigned_to || null,
|
||||||
submittedAt: derived.latestActivityAt,
|
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(() => {
|
const filteredRows = useMemo(() => {
|
||||||
return allRows
|
return allRows
|
||||||
@@ -620,7 +643,7 @@ export default function RequestsPage() {
|
|||||||
const renderRow = (row) => {
|
const renderRow = (row) => {
|
||||||
const project = projects.find(p => p.id === row.projectId);
|
const project = projects.find(p => p.id === row.projectId);
|
||||||
return (
|
return (
|
||||||
<tr key={row.id}>
|
<tr key={row.rowKey}>
|
||||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>
|
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>
|
||||||
{`R${String(row.version).padStart(2, '0')}`}
|
{`R${String(row.version).padStart(2, '0')}`}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -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))];
|
const taskIds = [...new Set(validItems.filter(i => i.task_id && !i.submission_id).map(i => i.task_id))];
|
||||||
if (taskIds.length > 0) {
|
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;
|
if (taskError) throw taskError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -81,13 +81,8 @@ export function TeamInvoiceDetailPanel({
|
|||||||
// Sync task statuses along invoice lifecycle
|
// Sync task statuses along invoice lifecycle
|
||||||
const { data: freshItems } = await supabase.from('invoice_items').select('task_id, submission_id').eq('invoice_id', invoiceId);
|
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);
|
const taskIds = (freshItems || []).filter(i => i.task_id && !i.submission_id).map(i => i.task_id);
|
||||||
if (taskIds.length > 0) {
|
// Task work status is not synced to invoice lifecycle.
|
||||||
const newTaskStatus = status === 'paid' ? 'paid' : status === 'sent' ? 'invoiced' : 'client_approved';
|
// Per-version status is derived from invoice_items at display time.
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
if (status === 'paid') {
|
if (status === 'paid') {
|
||||||
try {
|
try {
|
||||||
const contactEmail = invoice.invoice_email || await getDefaultInvoiceEmail(invoice.company_id, company);
|
const contactEmail = invoice.invoice_email || await getDefaultInvoiceEmail(invoice.company_id, company);
|
||||||
|
|||||||
@@ -410,7 +410,7 @@ export default function Invoices() {
|
|||||||
const validItems = invItems.filter(i => i.description);
|
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 })));
|
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))];
|
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))];
|
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 (subIds.length > 0) await supabase.from('submissions').update({ invoiced: true }).in('id', subIds);
|
||||||
if (status === 'sent') {
|
if (status === 'sent') {
|
||||||
|
|||||||
Reference in New Issue
Block a user