'$[9:kogI)MfH3]W^S(PBmW$Le?eCY$pSk!A)+RXO1Jgfi9A4fgGi5rXND#Cj)P%&;]V435ZcAce^9ZYSt%B%=2D9CEOSSd,qtD@Z-m??PJqK\3UbHGPfflnk[O+*>VGB'E*Qb0k+diK[pE4cJn '1d@B5NL/d9L,3+-*&73orSQpkeUqF18,?^V!Stc&(:AHU9(XN1D,W>eBr&Y4JkP97t?'l?1PGPf3m&^fnQ`Ik;Z1g+/]p5p[c@C[YXQuYV`(!`uA+&D-b8qd_[`4E8(>"1N/4d8"+'_DloJ3dnLn".`;LL_;BbNHkcG3KAej&4*VtLU*$!b"SpD)m`5)r5j2B#Hb:QhX2mc/Jn65IRN`\'[rkW,DWpKO-E@N-9$rVHU]!8E3';Wpp4dUniWm6N.EZ?*B_P%fW,.]>MM@p_->tY3'2=?OZEGXl+WI]iIS]O?"Ac9*@C]Nmhi9"g\j1?>e$B0hT&!ra,57-EZJoL!Q=pBA=22;sQs]t\,"iL>TLnq(EU-c%A$QQ;K@gseP@rnfHLh$9^)GPZJif==FR]LqGKLSEe'[Q.,5Z[aH];$f>/:nPPH7GTZIF>TWi`b0l^7fS>,M)&5!'i]7q_E(Qa>#S)/l+BQm/^AKMF3)eT?#'JsL6AQm+24dNu!-!4p8g[f]IC"MN(s_lmE[(K[`0`R:[p`lOYtQKqZaT]KepqrMq;8f*asQJTB+SL_;s\#sciB?/kOCkNZ]kGo:k]!JYS,H`d;9s$lE)GZ%Y#OkrdH*WkhWYG&D]sGAhWdSBJXOp,5:Ek%,Y(i3##AgV+TkqoHftY*Wah=,o'eDt%AgZ`eNh-O_cccM,*23CF_sqEM&K>7<=a'fWJS,(jZb);km2HpRL!Bm,&@/Vq2"IMpXkof^Irga#c$/Xe?0*Y`Bq"mF/Oa^N$70Y5?CrIECp_=*C9?iO9Q[A1G.>%.pTM#j'g-2:Bq]2&La?ODfSW](s/oapH,Y!C-o?"uMPaaTjP?k5$8:]7r*lFL8krl-G=lOTQg?2mls7R[Do!q%RUfKf,jUfSEQQ5h5Q4<0X#hn;"fZjdjr-/gV($&(8;_hF@.2&YIh6saqZE8hD=RJ%Im_O85sQT'?2oq97rpq,n6lSH%d&gcKhdcr-e%iaD09!@+pPs+/>A\K27cr/UCBi`kk"=`@V/C:l$9T(_GY,ER>pa:/PJ16Xl:3`'oF!(q#H;i^.+-K#C*3`;"T!>Pj!.5En[d"o~>endstream
+endobj
+13 0 obj
+<<
+/Filter [ /ASCII85Decode /FlateDecode ] /Length 2151
+>>
+stream
+Gb!lc>BA7Q'Z],&.F-/O'S[N3XP;%e""h[t"'oRrlnZIO851$o'@q0XiS3g`I#15Mf^9`H-FBV7-!C7hUnaP%fpDlZD]X@gY5jT;nG!k+/n"G\GT=[VHj,!0#QG5WVjXeE6nVNa=o=2m=UaQ6@$WagQj&I^V?qWr3/D%(B4*l:A'+7')8:Ls6Gs$:(>2"VZ[#u>Me*SA#D0e]$4bpO/eX6V@/(Uo%t3!;BM_hj(8/%DhAaS5&P>@-6NSHMZK-]fYFLllfuMDAE#"<;-6+"4-K?c$3LigLA-)^(Z:d0F_io'u\Ok^9\X=uqo@]!9hqJb/_N(bDYLueHKa;9!09\g+,OEG:14aFRe05`p?&gZW?`.TnbGO1THk*K.#+$PkE!3N.2G%>AgRAuP7U$=f_;KVl%O7!i?n,'b0AL3+_^m+OI*4?9`h=s(HDQL<[T5h_iS]TbE&]JTo3p^IW6We*H&`H'hUHKqq_lXNg)2D8Eaq#+DJtc?\D:2GIFHMCkI-k[FjC9RH>g&B`lf6Uh,B#5R,hfiBY\f7!9b?FU7>1@T@b]?NZj\R\p7RMie2j4_Hru+90D?lU'riS],^`TQ2p;$6`LBar\B\_?UX\72[DKs!Md'kaVaRn+qZ8nVJ#]K6g$kX,c@dOQqiaHomj*%^U%m_aYlVk%Ml3Ti&4r-T'mZuEJq6%(gtg[F-uCDUpW@A0H-kLD+JX;*RRS9!2!mBdkj_XQZolR'],s)>>J&Qe/Q@8fRr`D?Zh.G[L(rYUt0g>e7K0,dYJG-lpHc-.[DO-dn?KQf-jc_pAVKHJ"T^8@+I`<&@*G:VlkrDmd.=)/.c25W,`LWcaK,,sQ=c-SX57]2Z`A."dYq783ghXL>:F7f3bb++!ICk(G8TOsPIq:MJ!O?UCI5QH?>fp'G.Jh"[8=:@e>V#@Q/%73<.n>pa3e]eHNY_B>7"$iE3m"WTnV<,5'-J!?&"Q_:)@YHdBga7-CsU2W6P!`\d0[+^ZT/fGES?]Y(c:MOd>DFSr^$3*)G`VP6IJhDWNsZbD*Fp?&QDXHf`tqmB+aT8/I:f?+Iim-_8pr3<&LP$WN;sG26NadLpHiBkp*mZO0/p9BKg4$\WsdRZ0gq/4nepleP<>T2"(r:+kB%DM$K2gH/5q`^ptCf/du.;,'$d]7B`jaVC0iPj8`lXpD3J4>bg\DrZ6L]_U*L2'`X$%X>0DB6WT83oN09h=UWpoL%;,l?lC7a,V#(Z3BX#,=];X"56>[2U0((UL!D<7=0F6D;/>b4)*Vp5^O5,]8Lj\$/YdNZKEH_58b.12k$XFb,iQ*%qQna08h\endstream
+endobj
+xref
+0 14
+0000000000 65535 f
+0000000061 00000 n
+0000000112 00000 n
+0000000219 00000 n
+0000000331 00000 n
+0000000436 00000 n
+0000000632 00000 n
+0000000828 00000 n
+0000001024 00000 n
+0000001093 00000 n
+0000001373 00000 n
+0000001445 00000 n
+0000004582 00000 n
+0000006738 00000 n
+trailer
+<<
+/ID
+[]
+% ReportLab generated PDF document -- digest (opensource)
+
+/Info 9 0 R
+/Root 8 0 R
+/Size 14
+>>
+startxref
+8981
+%%EOF
diff --git a/docs/finance-summary.html b/docs/finance-summary.html
new file mode 100644
index 0000000..126200b
--- /dev/null
+++ b/docs/finance-summary.html
@@ -0,0 +1,339 @@
+
+
+
+
+
+ Fourge Portal Finance Summary
+
+
+
+
+
+ Fourge Portal Finance Summary
+ Role cheat sheet, finance lanes, and data flow for team, client, and subcontractor users.
+
+
+
+ 1. Executive Summary
+ The finance area is not one shared page for all roles. It is split into three lanes with different permissions and different page sets.
+
+
+ Team
+ Full finance hub at /invoices .
+ Handles client invoices, expenses, subcontractor invoices, and subcontractor purchase orders.
+
+
+ Client
+ Invoice-only lane at /my-invoices .
+ Clients can view invoices, filter by company, and download PDFs.
+
+
+ Subcontractor
+ Two finance lanes: /my-invoices-sub and /my-purchase-orders .
+ They can submit invoices to Fourge and approve POs sent by Fourge.
+
+
+
+
+
+ 2. Role-by-Role Cheat Sheet
+
+
+
+ Role
+ Main Routes
+ Can Do
+ Cannot Do
+
+
+
+
+ Team
+ /invoices
+ Create/send client invoices, track expenses, create POs, review subcontractor invoices, mark paid, export/send PDFs
+ Not limited in finance UI
+
+
+ Client
+ /my-invoices
+ View invoices, filter by company, download invoice PDFs
+ No expenses, no invoice editing, no POs, no subcontractor finance tools
+
+
+ Subcontractor / External
+ /my-invoices-sub /my-purchase-orders
+ Create/submit invoices to Fourge, view status, download receipts after payment, approve POs
+ No team finance overview, no client invoice management, no internal expenses
+
+
+
+
+
+
+ 3. Core Data Models
+
+
invoices
+
invoice_items
+
expenses
+
subcontractor_invoices
+
subcontractor_invoice_items
+
subcontractor_payments
+
subcontractor_po_items
+
tasks
+
submissions
+
companies
+
profiles
+
Simple mental model: invoices bill clients, expenses track internal costs, subcontractor_invoices let subcontractors bill Fourge, and subcontractor_payments are POs Fourge sends to subcontractors.
+
+
+
+
+ 4. System Flow
+
+
+
Client Billing Flow
+
Approved client work reaches tasks.status = client_approved .
+
Team finance loads uninvoiced tasks and revisions, builds line items, saves an invoice, and emails the PDF to the client.
+
Later, team marks the invoice paid.
+
+
+
Expense Flow
+
Team logs expenses, optionally with receipt files.
+
Expenses feed overview charts, yearly summaries, and profit calculations.
+
+
+
Subcontractor Invoice Flow
+
Subcontractor creates an invoice from completed approved tasks assigned to them.
+
Fourge reviews it, then marks it paid and sends a receipt PDF back.
+
+
+
Purchase Order Flow
+
Team creates a PO for a subcontractor, optionally tied to project tasks.
+
Subcontractor reviews the PO and can approve it from their own portal view.
+
+
+
+
+
+ 5. Status Logic
+
+
+ Client Invoice
+ draft → sent → paid
+
+
+ Subcontractor Invoice
+ draft → submitted → paid
+
+
+ Purchase Order
+ draft → sent → approved → ready_to_pay → paid
+ cancelled is exit state.
+
+
+
+
+
+ 6. Key Business Rules
+
+ Client invoices only pull approved, uninvoiced work.
+ Tasks and revisions are marked invoiced after invoice creation.
+ Deleting a team invoice rolls linked invoice flags back.
+ Team invoice status changes also push task status changes.
+ Revision billing rule: R00 = new work , R01 = free , R02+ = billable increments .
+ Subcontractor invoices are created by the external user and then reviewed/paid by team.
+ POs are created by team and approved by the subcontractor.
+
+
+
+
+ 7. Database Relationship Chart
+ COMPANIES
+ └─ invoices
+ └─ invoice_items
+ ├─ task_id → tasks
+ └─ submission_id → submissions
+
+PROJECTS
+ └─ tasks
+ └─ submissions
+
+PROFILES (external)
+ ├─ subcontractor_invoices
+ │ └─ subcontractor_invoice_items
+ │ └─ task_id → tasks
+ └─ subcontractor_payments (POs)
+ └─ subcontractor_po_items
+ └─ task_id → tasks
+
+EXPENSES
+ └─ standalone internal finance records
+
+
+
+ 8. Quick Route Map
+
+
+
+ Surface
+ Route
+ User
+
+
+
+ Finance Hub /invoices Team
+ Client Invoices /my-invoices Client
+ Subcontractor Invoices /my-invoices-sub External
+ Create Subcontractor Invoice /my-invoices-sub/new External
+ Purchase Orders /my-purchase-orders External
+
+
+
+
+
+
+
+
diff --git a/docs/finance-summary.pdf b/docs/finance-summary.pdf
new file mode 100644
index 0000000..8fb8f25
Binary files /dev/null and b/docs/finance-summary.pdf differ
diff --git a/docs/generate-checked-sites-audit-html.mjs b/docs/generate-checked-sites-audit-html.mjs
new file mode 100644
index 0000000..99a2c8b
--- /dev/null
+++ b/docs/generate-checked-sites-audit-html.mjs
@@ -0,0 +1,379 @@
+import fs from 'node:fs';
+import { fileURLToPath } from 'node:url';
+
+const docsDir = new URL('.', import.meta.url);
+const inputPath = new URL('./checked-sites-revision-audit-2026-06-04.json', docsDir);
+const outputPath = new URL('./checked-sites-revision-audit-2026-06-04.html', docsDir);
+
+const rows = JSON.parse(fs.readFileSync(inputPath, 'utf8'));
+
+const existingRows = rows.filter((row) => row.exists !== false);
+const activeRows = existingRows.filter((row) => row.hasActiveRevision === 'Yes');
+const noActiveRows = existingRows.filter((row) => row.hasActiveRevision !== 'Yes');
+const r00BilledRows = existingRows.filter((row) => row.r00Billed !== 'No');
+const r00UnbilledRows = existingRows.filter((row) => row.r00Billed === 'No');
+
+const activeStatusCounts = activeRows.reduce((acc, row) => {
+ const key = row.activeStatus || 'unknown';
+ acc[key] = (acc[key] || 0) + 1;
+ return acc;
+}, {});
+
+const revisionDepthCounts = activeRows.reduce((acc, row) => {
+ const key = row.activeRevision || '—';
+ acc[key] = (acc[key] || 0) + 1;
+ return acc;
+}, {});
+
+const maxStatusCount = Math.max(1, ...Object.values(activeStatusCounts));
+const maxDepthCount = Math.max(1, ...Object.values(revisionDepthCounts));
+
+const esc = (value) =>
+ String(value ?? '')
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+
+function badgeClassForStatus(status) {
+ if (status === 'client_review') return 'review';
+ if (status === 'client_approved') return 'approved';
+ if (status === 'not_started') return 'todo';
+ if (status === 'in_progress') return 'progress';
+ return 'neutral';
+}
+
+function barChart(entries, maxValue) {
+ return entries
+ .map(([label, count]) => {
+ const pct = Math.max(4, Math.round((count / maxValue) * 100));
+ return `
+
+
${esc(label)}
+
+
${count}
+
+ `;
+ })
+ .join('');
+}
+
+function renderTableRows(items) {
+ return items
+ .map((row) => {
+ const billedClass = row.r00Billed === 'No' ? 'warn' : 'ok';
+ const activeClass = badgeClassForStatus(row.activeStatus);
+ return `
+
+ ${esc(row.site)}
+ ${esc(row.dbTitle)}
+ ${esc(row.internalTaskNumber)}
+ ${esc(row.r00Completed)}
+ ${esc(row.r00Billed)}
+ ${esc(row.hasActiveRevision)}
+ ${esc(row.activeRevision)}
+ ${esc(row.activeStatus)}
+ ${esc(row.completedRevisions)}
+ ${esc(row.billedVersions)}
+ ${esc(row.versionSummary)}
+
+ `;
+ })
+ .join('');
+}
+
+const html = `
+
+
+
+
+ Checked Sites Revision Audit
+
+
+
+
+
+ Fourge Portal Audit
+ Checked Sites Revision Audit
+ This report treats your checkmark as “R00 was completed,” then separates that from billing and from any newer active revisions. It is designed to make client-billing comparison easier at a glance.
+
+
+
+
+
Checked Sites
+
${existingRows.length}
+
Sites found in the database from your checked list.
+
+
+
Active Revisions
+
${activeRows.length}
+
Checked sites that now have an active R01+.
+
+
+
R00 Billed
+
${r00BilledRows.length}
+
Checked sites where the new-book unit is already on an invoice.
+
+
+
R00 Unbilled
+
${r00UnbilledRows.length}
+
Checked sites completed at R00 but not yet billed.
+
+
+
+
+
+
Active Revision Status
+ ${barChart(Object.entries(activeStatusCounts), maxStatusCount)}
+
+
+
Active Revision Depth
+ ${barChart(Object.entries(revisionDepthCounts), maxDepthCount)}
+
+
+
+
+ Sites With Active Revisions
+ These are the ones where R00 may be done, but a newer version is still active or awaiting review/approval.
+
+
+
+
+ Site #
+ DB Title
+ Internal #
+ R00 Done
+ R00 Billed
+ Active R#
+ Active Status
+ Completed Revisions
+ Billed Versions
+ Version Summary
+
+
+
+ ${renderTableRows(activeRows)}
+
+
+
+
+
+
+ Checked Sites With No Active Revision
+ These are simpler to read: R00 completed, and no current revision chain is active right now.
+
+
+
+
+ Site #
+ DB Title
+ Internal #
+ R00 Done
+ R00 Billed
+ Active R#
+ Active Status
+ Completed Revisions
+ Billed Versions
+ Version Summary
+
+
+
+ ${renderTableRows(noActiveRows)}
+
+
+
+
+
+
+`;
+
+fs.writeFileSync(outputPath, html);
+console.log(fileURLToPath(outputPath));
diff --git a/docs/generate-checked-sites-audit-pdf.py b/docs/generate-checked-sites-audit-pdf.py
new file mode 100644
index 0000000..705b919
--- /dev/null
+++ b/docs/generate-checked-sites-audit-pdf.py
@@ -0,0 +1,362 @@
+from pathlib import Path
+import json
+
+from reportlab.lib import colors
+from reportlab.lib.enums import TA_LEFT
+from reportlab.lib.pagesizes import landscape, legal
+from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
+from reportlab.lib.units import inch
+from reportlab.platypus import (
+ BaseDocTemplate,
+ Frame,
+ LongTable,
+ PageBreak,
+ PageTemplate,
+ Paragraph,
+ Spacer,
+ Table,
+ TableStyle,
+)
+
+
+DOCS_DIR = Path(__file__).resolve().parent
+INPUT_PATH = DOCS_DIR / "checked-sites-revision-audit-2026-06-04.json"
+OUTPUT_PATH = DOCS_DIR / "checked-sites-revision-audit-2026-06-04.pdf"
+
+BG = colors.HexColor("#F5F1E8")
+TEXT = colors.HexColor("#231D14")
+MUTED = colors.HexColor("#6B6255")
+ACCENT = colors.HexColor("#B8831F")
+ACCENT_SOFT = colors.HexColor("#F1E3C2")
+OK = colors.HexColor("#1F8A4C")
+OK_SOFT = colors.HexColor("#E5F4EB")
+WARN = colors.HexColor("#B45309")
+WARN_SOFT = colors.HexColor("#F7E7D7")
+REVIEW = colors.HexColor("#2563EB")
+REVIEW_SOFT = colors.HexColor("#E6EEFF")
+TODO = colors.HexColor("#7C3AED")
+TODO_SOFT = colors.HexColor("#F0E8FF")
+LINE = colors.HexColor("#D7CFC1")
+
+
+def page_template(canvas, doc):
+ canvas.saveState()
+ canvas.setFillColor(BG)
+ canvas.rect(0, 0, doc.pagesize[0], doc.pagesize[1], fill=1, stroke=0)
+ canvas.setFillColor(MUTED)
+ canvas.setFont("Helvetica", 9)
+ canvas.drawRightString(doc.pagesize[0] - 36, 20, f"Page {doc.page}")
+ canvas.restoreState()
+
+
+def pill(text, fg, bg):
+ return Paragraph(
+ (
+ f'{escape(text)} '
+ ),
+ styles["pill"],
+ )
+
+
+def escape(value):
+ return (
+ str(value or "")
+ .replace("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+ )
+
+
+def status_pill(value):
+ value = value or "—"
+ if value == "client_review":
+ return pill(value, REVIEW, REVIEW_SOFT)
+ if value == "client_approved":
+ return pill(value, OK, OK_SOFT)
+ if value == "not_started":
+ return pill(value, TODO, TODO_SOFT)
+ if value == "in_progress":
+ return pill(value, ACCENT, ACCENT_SOFT)
+ if value == "Yes":
+ return pill(value, OK, OK_SOFT)
+ if value == "No":
+ return pill(value, WARN, WARN_SOFT)
+ return pill(value, MUTED, colors.white)
+
+
+def text_para(value, style_name="cell"):
+ return Paragraph(escape(value), styles[style_name])
+
+
+def build_summary_cards(rows):
+ existing_rows = [row for row in rows if row.get("exists", True) is not False]
+ active_rows = [row for row in existing_rows if row.get("hasActiveRevision") == "Yes"]
+ billed_rows = [row for row in existing_rows if row.get("r00Billed") != "No"]
+ unbilled_rows = [row for row in existing_rows if row.get("r00Billed") == "No"]
+
+ cards = [
+ ("Checked Sites", str(len(existing_rows)), "Sites found in the database from your checked list."),
+ ("Active Revisions", str(len(active_rows)), "Checked sites that now have an active R01+."),
+ ("R00 Billed", str(len(billed_rows)), "Checked sites where the new-book unit is already billed."),
+ ("R00 Unbilled", str(len(unbilled_rows)), "Checked sites where R00 is done but not billed yet."),
+ ]
+
+ card_rows = []
+ for label, value, subtext in cards:
+ card_rows.append(
+ Table(
+ [
+ [Paragraph(label.upper(), styles["card_label"])],
+ [Paragraph(value, styles["card_value"])],
+ [Paragraph(subtext, styles["card_sub"])],
+ ],
+ colWidths=[3.0 * inch],
+ style=TableStyle(
+ [
+ ("BACKGROUND", (0, 0), (-1, -1), colors.white),
+ ("BOX", (0, 0), (-1, -1), 0.8, LINE),
+ ("INNERPADDING", (0, 0), (-1, -1), 10),
+ ("ROUNDEDCORNERS", [10, 10, 10, 10]),
+ ]
+ ),
+ )
+ )
+ return Table([card_rows], colWidths=[3.1 * inch] * 4, hAlign="LEFT")
+
+
+def build_table(rows, title, intro):
+ flow = [Paragraph(title, styles["section_title"]), Paragraph(intro, styles["small"]), Spacer(1, 10)]
+
+ header = [
+ text_para("Site", "th"),
+ text_para("DB Title", "th"),
+ text_para("Internal #", "th"),
+ text_para("R00 Done", "th"),
+ text_para("R00 Billed", "th"),
+ text_para("Active R#", "th"),
+ text_para("Active Status", "th"),
+ text_para("Completed Revisions", "th"),
+ text_para("Billed Versions", "th"),
+ text_para("Version Summary", "th"),
+ ]
+ data = [header]
+
+ for row in rows:
+ data.append(
+ [
+ text_para(row.get("site", ""), "cell_mono"),
+ text_para(row.get("dbTitle", "")),
+ text_para(row.get("internalTaskNumber", ""), "cell_mono"),
+ status_pill(row.get("r00Completed", "No")),
+ status_pill(row.get("r00Billed", "No")),
+ text_para(row.get("activeRevision", "—"), "cell_mono"),
+ status_pill(row.get("activeStatus", "—")),
+ text_para(row.get("completedRevisions", "—")),
+ text_para(row.get("billedVersions", "—")),
+ text_para(row.get("versionSummary", "—")),
+ ]
+ )
+
+ table = LongTable(
+ data,
+ colWidths=[
+ 0.78 * inch,
+ 1.7 * inch,
+ 0.72 * inch,
+ 0.75 * inch,
+ 0.85 * inch,
+ 0.6 * inch,
+ 1.0 * inch,
+ 1.45 * inch,
+ 1.2 * inch,
+ 2.15 * inch,
+ ],
+ repeatRows=1,
+ )
+ table.setStyle(
+ TableStyle(
+ [
+ ("BACKGROUND", (0, 0), (-1, 0), colors.white),
+ ("BOX", (0, 0), (-1, -1), 0.8, LINE),
+ ("INNERGRID", (0, 0), (-1, -1), 0.4, LINE),
+ ("VALIGN", (0, 0), (-1, -1), "TOP"),
+ ("TOPPADDING", (0, 0), (-1, -1), 6),
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
+ ("LEFTPADDING", (0, 0), (-1, -1), 6),
+ ("RIGHTPADDING", (0, 0), (-1, -1), 6),
+ ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#FBF8F2")]),
+ ]
+ )
+ )
+ flow.append(table)
+ return flow
+
+
+styles = getSampleStyleSheet()
+styles.add(
+ ParagraphStyle(
+ name="title_main",
+ fontName="Helvetica-Bold",
+ fontSize=24,
+ leading=28,
+ textColor=TEXT,
+ alignment=TA_LEFT,
+ )
+)
+styles.add(
+ ParagraphStyle(
+ name="eyebrow",
+ fontName="Helvetica-Bold",
+ fontSize=9,
+ leading=11,
+ textColor=MUTED,
+ )
+)
+styles.add(
+ ParagraphStyle(
+ name="subtitle",
+ fontName="Helvetica",
+ fontSize=11,
+ leading=15,
+ textColor=MUTED,
+ )
+)
+styles.add(
+ ParagraphStyle(
+ name="section_title",
+ fontName="Helvetica-Bold",
+ fontSize=15,
+ leading=18,
+ textColor=TEXT,
+ spaceAfter=2,
+ )
+)
+styles.add(
+ ParagraphStyle(
+ name="small",
+ fontName="Helvetica",
+ fontSize=9,
+ leading=12,
+ textColor=MUTED,
+ )
+)
+styles.add(
+ ParagraphStyle(
+ name="card_label",
+ fontName="Helvetica-Bold",
+ fontSize=8,
+ leading=10,
+ textColor=MUTED,
+ )
+)
+styles.add(
+ ParagraphStyle(
+ name="card_value",
+ fontName="Helvetica-Bold",
+ fontSize=24,
+ leading=26,
+ textColor=TEXT,
+ )
+)
+styles.add(
+ ParagraphStyle(
+ name="card_sub",
+ fontName="Helvetica",
+ fontSize=8.5,
+ leading=11,
+ textColor=MUTED,
+ )
+)
+styles.add(
+ ParagraphStyle(
+ name="th",
+ fontName="Helvetica-Bold",
+ fontSize=8,
+ leading=10,
+ textColor=MUTED,
+ )
+)
+styles.add(
+ ParagraphStyle(
+ name="cell",
+ fontName="Helvetica",
+ fontSize=8.2,
+ leading=10.5,
+ textColor=TEXT,
+ )
+)
+styles.add(
+ ParagraphStyle(
+ name="cell_mono",
+ fontName="Courier",
+ fontSize=8.1,
+ leading=10.3,
+ textColor=TEXT,
+ )
+)
+styles.add(
+ ParagraphStyle(
+ name="pill",
+ fontName="Helvetica-Bold",
+ fontSize=7.5,
+ leading=9,
+ alignment=1,
+ )
+)
+
+
+def main():
+ rows = json.loads(INPUT_PATH.read_text())
+ existing_rows = [row for row in rows if row.get("exists", True) is not False]
+ active_rows = [row for row in existing_rows if row.get("hasActiveRevision") == "Yes"]
+ no_active_rows = [row for row in existing_rows if row.get("hasActiveRevision") != "Yes"]
+
+ doc = BaseDocTemplate(
+ str(OUTPUT_PATH),
+ pagesize=landscape(legal),
+ leftMargin=36,
+ rightMargin=36,
+ topMargin=28,
+ bottomMargin=28,
+ )
+ frame = Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height, id="main")
+ doc.addPageTemplates([PageTemplate(id="default", frames=[frame], onPage=page_template)])
+
+ story = [
+ Paragraph("FOURGE PORTAL AUDIT", styles["eyebrow"]),
+ Spacer(1, 4),
+ Paragraph("Checked Sites Revision Audit", styles["title_main"]),
+ Spacer(1, 6),
+ Paragraph(
+ "This report treats your checkmark as “R00 was completed,” then separates that from billing and from any newer active revisions. "
+ "It is designed to make client-billing comparison easier at a glance.",
+ styles["subtitle"],
+ ),
+ Spacer(1, 18),
+ build_summary_cards(rows),
+ Spacer(1, 18),
+ ]
+
+ story.extend(
+ build_table(
+ active_rows,
+ "Checked Sites With Active Revisions",
+ "These are the sites where R00 is done, but there is still a newer revision active or awaiting billing.",
+ )
+ )
+ story.append(PageBreak())
+ story.extend(
+ build_table(
+ no_active_rows,
+ "Checked Sites With No Active Revision",
+ "These are the checked sites that currently do not have a newer active revision.",
+ )
+ )
+
+ doc.build(story)
+ print(OUTPUT_PATH)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/docs/generate-finance-pdf.mjs b/docs/generate-finance-pdf.mjs
new file mode 100644
index 0000000..c09f667
--- /dev/null
+++ b/docs/generate-finance-pdf.mjs
@@ -0,0 +1,301 @@
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { createRequire } from 'node:module';
+import { fileURLToPath } from 'node:url';
+
+const require = createRequire(import.meta.url);
+const { PDFDocument, StandardFonts, rgb } = require('pdf-lib');
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+const outputPath = path.join(__dirname, 'finance-summary.pdf');
+
+const pdf = await PDFDocument.create();
+const fontRegular = await pdf.embedFont(StandardFonts.Helvetica);
+const fontBold = await pdf.embedFont(StandardFonts.HelveticaBold);
+
+const PAGE_W = 612;
+const PAGE_H = 792;
+const MARGIN = 42;
+const ACCENT = rgb(0.96, 0.65, 0.14);
+const TEXT = rgb(0.09, 0.09, 0.09);
+const MUTED = rgb(0.36, 0.36, 0.36);
+const BORDER = rgb(0.90, 0.86, 0.80);
+const SURFACE = rgb(0.985, 0.97, 0.94);
+const WHITE = rgb(1, 1, 1);
+
+let page = pdf.addPage([PAGE_W, PAGE_H]);
+let y = PAGE_H - MARGIN;
+
+function ensureSpace(heightNeeded = 40) {
+ if (y - heightNeeded < MARGIN) {
+ page = pdf.addPage([PAGE_W, PAGE_H]);
+ y = PAGE_H - MARGIN;
+ }
+}
+
+function drawText(text, x, yy, size = 12, font = fontRegular, color = TEXT) {
+ page.drawText(text, { x, y: yy, size, font, color });
+}
+
+function wrapText(text, maxWidth, size = 12, font = fontRegular) {
+ const words = String(text).split(/\s+/);
+ const lines = [];
+ let line = '';
+ for (const word of words) {
+ const test = line ? `${line} ${word}` : word;
+ const width = font.widthOfTextAtSize(test, size);
+ if (width <= maxWidth || !line) line = test;
+ else {
+ lines.push(line);
+ line = word;
+ }
+ }
+ if (line) lines.push(line);
+ return lines;
+}
+
+function drawParagraph(text, x, maxWidth, size = 12, color = TEXT, lineGap = 4, font = fontRegular) {
+ const lines = wrapText(text, maxWidth, size, font);
+ const lineHeight = size + lineGap;
+ ensureSpace(lines.length * lineHeight + 4);
+ for (const line of lines) {
+ drawText(line, x, y, size, font, color);
+ y -= lineHeight;
+ }
+ y -= 4;
+}
+
+function drawSectionTitle(text) {
+ ensureSpace(30);
+ drawText(text, MARGIN, y, 18, fontBold, TEXT);
+ y -= 26;
+}
+
+function drawCard(x, yy, w, h, title, bodyLines) {
+ page.drawRectangle({ x, y: yy - h, width: w, height: h, color: SURFACE, borderColor: BORDER, borderWidth: 1, borderRadius: 12 });
+ drawText(title, x + 12, yy - 20, 11, fontBold, MUTED);
+ let innerY = yy - 40;
+ for (const line of bodyLines) {
+ const wrapped = wrapText(line, w - 24, 11, fontRegular);
+ for (const piece of wrapped) {
+ drawText(piece, x + 12, innerY, 11, fontRegular, TEXT);
+ innerY -= 15;
+ }
+ innerY -= 2;
+ }
+}
+
+function drawFlowBox(title, lines) {
+ const contentLines = lines.flatMap(line => wrapText(line, PAGE_W - MARGIN * 2 - 24, 11, fontRegular));
+ const h = 40 + contentLines.length * 15 + 10;
+ ensureSpace(h + 24);
+ page.drawRectangle({ x: MARGIN, y: y - h, width: PAGE_W - MARGIN * 2, height: h, color: WHITE, borderColor: BORDER, borderWidth: 1, borderRadius: 12 });
+ drawText(title, MARGIN + 14, y - 20, 11, fontBold, MUTED);
+ let innerY = y - 40;
+ for (const line of contentLines) {
+ drawText(line, MARGIN + 14, innerY, 11, fontRegular, TEXT);
+ innerY -= 15;
+ }
+ y -= h + 18;
+ if (y > MARGIN + 40) {
+ drawText("v", PAGE_W / 2 - 3, y + 3, 14, fontBold, ACCENT);
+ }
+}
+
+function drawBullet(text) {
+ const bulletX = MARGIN + 4;
+ const textX = MARGIN + 18;
+ const maxWidth = PAGE_W - textX - MARGIN;
+ const lines = wrapText(text, maxWidth, 11, fontRegular);
+ const lineHeight = 15;
+ ensureSpace(lines.length * lineHeight + 4);
+ drawText("•", bulletX, y, 12, fontBold, TEXT);
+ for (const line of lines) {
+ drawText(line, textX, y, 11, fontRegular, TEXT);
+ y -= lineHeight;
+ }
+ y -= 2;
+}
+
+function drawTable(headers, rows, colWidths) {
+ const tableW = colWidths.reduce((a, b) => a + b, 0);
+ const startX = MARGIN;
+ const rowPad = 8;
+ const headerH = 24;
+ ensureSpace(36);
+ page.drawRectangle({ x: startX, y: y - headerH, width: tableW, height: headerH, color: SURFACE, borderColor: BORDER, borderWidth: 1 });
+ let x = startX;
+ headers.forEach((head, i) => {
+ drawText(head, x + 6, y - 15, 10, fontBold, MUTED);
+ x += colWidths[i];
+ });
+ y -= headerH;
+ for (const row of rows) {
+ const wrappedCells = row.map((cell, i) => wrapText(cell, colWidths[i] - 12, 10, fontRegular));
+ const lines = Math.max(...wrappedCells.map(c => c.length));
+ const rowH = Math.max(22, lines * 13 + rowPad);
+ ensureSpace(rowH + 2);
+ page.drawRectangle({ x: startX, y: y - rowH, width: tableW, height: rowH, borderColor: BORDER, borderWidth: 1 });
+ let cellX = startX;
+ wrappedCells.forEach((cellLines, i) => {
+ let cellY = y - 14;
+ for (const line of cellLines) {
+ drawText(line, cellX + 6, cellY, 10, fontRegular, TEXT);
+ cellY -= 12;
+ }
+ cellX += colWidths[i];
+ });
+ y -= rowH;
+ }
+ y -= 10;
+}
+
+function newPage() {
+ page = pdf.addPage([PAGE_W, PAGE_H]);
+ y = PAGE_H - MARGIN;
+}
+
+// Page 1
+page.drawRectangle({
+ x: MARGIN,
+ y: y - 88,
+ width: PAGE_W - MARGIN * 2,
+ height: 88,
+ color: SURFACE,
+ borderColor: BORDER,
+ borderWidth: 1.2,
+ borderRadius: 16,
+});
+drawText('Fourge Portal Finance Summary', MARGIN + 18, y - 30, 24, fontBold, TEXT);
+drawText('Role cheat sheet, finance lanes, and data flow for team, client, and subcontractor users.', MARGIN + 18, y - 54, 12, fontRegular, MUTED);
+y -= 112;
+
+drawSectionTitle('1. Executive Summary');
+drawParagraph('The finance area is split into three separate role lanes. Team gets the full finance hub, clients get invoice viewing only, and subcontractors get invoice submission plus purchase-order review.', MARGIN, PAGE_W - MARGIN * 2, 12, TEXT);
+
+ensureSpace(140);
+const gap = 12;
+const cardW = (PAGE_W - MARGIN * 2 - gap * 2) / 3;
+drawCard(MARGIN, y, cardW, 104, 'TEAM', [
+ 'Main route: /invoices',
+ 'Handles client invoices, expenses, subcontractor invoices, and POs.',
+]);
+drawCard(MARGIN + cardW + gap, y, cardW, 104, 'CLIENT', [
+ 'Main route: /my-invoices',
+ 'Can view invoices, filter by company, and download PDFs.',
+]);
+drawCard(MARGIN + (cardW + gap) * 2, y, cardW, 104, 'SUBCONTRACTOR', [
+ 'Main routes: /my-invoices-sub and /my-purchase-orders',
+ 'Can submit invoices to Fourge and approve POs.',
+]);
+y -= 126;
+
+drawSectionTitle('2. Role-by-Role Cheat Sheet');
+drawTable(
+ ['ROLE', 'MAIN ROUTES', 'CAN DO', 'CANNOT DO'],
+ [
+ ['Team', '/invoices', 'Client invoices, expenses, POs, subcontractor invoices, paid status, PDFs, email sends', 'No major finance restrictions in UI'],
+ ['Client', '/my-invoices', 'View invoices, filter by company, download invoice PDFs', 'No expenses, no edits, no POs, no subcontractor finance tools'],
+ ['Subcontractor', '/my-invoices-sub /my-purchase-orders', 'Submit invoices, view status, download receipt after paid, approve POs', 'No team finance overview, no client billing control'],
+ ],
+ [72, 120, 205, 131],
+);
+
+drawSectionTitle('3. Core Data Models');
+drawBullet('invoices + invoice_items: Fourge bills clients.');
+drawBullet('expenses: Fourge internal finance records.');
+drawBullet('subcontractor_invoices + subcontractor_invoice_items: subcontractors bill Fourge.');
+drawBullet('subcontractor_payments + subcontractor_po_items: Fourge sends purchase orders to subcontractors.');
+drawBullet('tasks and submissions connect work records to invoice line items.');
+
+// Page 2
+newPage();
+drawSectionTitle('4. System Flow');
+drawFlowBox('CLIENT BILLING FLOW', [
+ 'Approved work reaches tasks.status = client_approved.',
+ 'Team finance loads uninvoiced tasks and revisions and builds invoice line items.',
+ 'Invoice is saved, PDF is generated, and email is sent to the client.',
+ 'Later team marks the invoice paid.',
+]);
+drawFlowBox('EXPENSE FLOW', [
+ 'Team logs internal expenses and can attach receipt files.',
+ 'Expenses feed overview charts, yearly finance summaries, and profit calculations.',
+]);
+drawFlowBox('SUBCONTRACTOR INVOICE FLOW', [
+ 'Subcontractor creates an invoice from completed approved tasks assigned to them.',
+ 'Fourge reviews the invoice, marks it paid, and can send a receipt PDF back.',
+]);
+drawFlowBox('PURCHASE ORDER FLOW', [
+ 'Team creates a PO for a subcontractor, optionally linked to project tasks.',
+ 'Subcontractor reviews and approves the PO from their own portal view.',
+]);
+
+y -= 8;
+drawSectionTitle('5. Status Logic');
+drawBullet('Client invoice: draft -> sent -> paid');
+drawBullet('Subcontractor invoice: draft -> submitted -> paid');
+drawBullet('Purchase order: draft -> sent -> approved -> ready_to_pay -> paid');
+drawBullet('Cancelled is the exit state for a PO.');
+
+drawSectionTitle('6. Key Business Rules');
+drawBullet('Client invoices only pull approved, uninvoiced work.');
+drawBullet('Tasks and revisions are marked invoiced after invoice creation.');
+drawBullet('Deleting a team invoice rolls linked invoice flags back.');
+drawBullet('Team invoice status changes also push task status changes.');
+drawBullet('Revision billing rule: R00 = new work, R01 = free, R02+ = billable increments.');
+drawBullet('Subcontractor invoices are created by the external user and then reviewed/paid by team.');
+drawBullet('POs are created by team and approved by the subcontractor.');
+
+// Page 3
+newPage();
+drawSectionTitle('7. Database Relationship Chart');
+drawParagraph('Simple relationship map:', MARGIN, PAGE_W - MARGIN * 2, 12, TEXT, 4, fontBold);
+const diagram = [
+ 'COMPANIES',
+ ' -> invoices',
+ ' -> invoice_items',
+ ' -> task_id -> tasks',
+ ' -> submission_id -> submissions',
+ '',
+ 'PROJECTS',
+ ' -> tasks',
+ ' -> submissions',
+ '',
+ 'PROFILES (external)',
+ ' -> subcontractor_invoices',
+ ' -> subcontractor_invoice_items',
+ ' -> task_id -> tasks',
+ ' -> subcontractor_payments (POs)',
+ ' -> subcontractor_po_items',
+ ' -> task_id -> tasks',
+ '',
+ 'EXPENSES',
+ ' -> standalone internal finance records',
+];
+page.drawRectangle({ x: MARGIN, y: y - 270, width: PAGE_W - MARGIN * 2, height: 270, color: SURFACE, borderColor: BORDER, borderWidth: 1, borderRadius: 12 });
+let codeY = y - 20;
+for (const line of diagram) {
+ drawText(line, MARGIN + 14, codeY, 11, fontRegular, TEXT);
+ codeY -= 14;
+}
+y -= 292;
+
+drawSectionTitle('8. Quick Route Map');
+drawTable(
+ ['SURFACE', 'ROUTE', 'USER'],
+ [
+ ['Finance Hub', '/invoices', 'Team'],
+ ['Client Invoices', '/my-invoices', 'Client'],
+ ['Subcontractor Invoices', '/my-invoices-sub', 'External'],
+ ['Create Subcontractor Invoice', '/my-invoices-sub/new', 'External'],
+ ['Purchase Orders', '/my-purchase-orders', 'External'],
+ ],
+ [210, 230, 88],
+);
+
+drawParagraph('Generated from the current portal codebase summary on June 3, 2026.', MARGIN, PAGE_W - MARGIN * 2, 10, MUTED);
+
+const bytes = await pdf.save();
+await fs.writeFile(outputPath, bytes);
+console.log(outputPath);
diff --git a/docs/invoice-match-audit-2026-06-05.json b/docs/invoice-match-audit-2026-06-05.json
new file mode 100644
index 0000000..84def3f
--- /dev/null
+++ b/docs/invoice-match-audit-2026-06-05.json
@@ -0,0 +1,1376 @@
+{
+ "checkedCount": 26,
+ "foundCount": 39,
+ "missingFromDb": [
+ "00278",
+ "00279",
+ "00281",
+ "00282",
+ "00284",
+ "00290",
+ "00291",
+ "30051",
+ "30054",
+ "30056",
+ "30058",
+ "30061",
+ "30063",
+ "30068",
+ "30095",
+ "30098",
+ "30210",
+ "30262",
+ "30319",
+ "30342",
+ "00288",
+ "30344",
+ "30345",
+ "30348",
+ "30349",
+ "30350",
+ "30355",
+ "30357",
+ "30407",
+ "68175",
+ "30412",
+ "30413",
+ "68215",
+ "68216",
+ "30437",
+ "68220",
+ "68432",
+ "68516",
+ "68617",
+ "68620",
+ "68631",
+ "68636",
+ "68637",
+ "68638",
+ "68715",
+ "68723",
+ "68751",
+ "68776",
+ "68777",
+ "68778",
+ "68779",
+ "68780",
+ "68781",
+ "68782",
+ "68784",
+ "68785"
+ ],
+ "checkedNotBilledR00": [
+ {
+ "site": "30094",
+ "checked": true,
+ "exists": true,
+ "title": "30094 Colorado Springs, CO",
+ "taskNumber": 35,
+ "status": "client_approved",
+ "currentVersion": "R00",
+ "billedVersions": [],
+ "completedUnbilledVersions": [
+ "R00"
+ ],
+ "activeRevision": "—",
+ "activeStatus": "—",
+ "r00Billed": false
+ },
+ {
+ "site": "30261",
+ "checked": true,
+ "exists": true,
+ "title": "30261 NSA Clovis NM",
+ "taskNumber": 70,
+ "status": "client_approved",
+ "currentVersion": "R00",
+ "billedVersions": [],
+ "completedUnbilledVersions": [
+ "R00"
+ ],
+ "activeRevision": "—",
+ "activeStatus": "—",
+ "r00Billed": false
+ },
+ {
+ "site": "68720",
+ "checked": true,
+ "exists": true,
+ "title": "68720 NSA Lancaster PA",
+ "taskNumber": 74,
+ "status": "client_approved",
+ "currentVersion": "R00",
+ "billedVersions": [],
+ "completedUnbilledVersions": [
+ "R00"
+ ],
+ "activeRevision": "—",
+ "activeStatus": "—",
+ "r00Billed": false
+ }
+ ],
+ "uncheckedButBilled": [],
+ "checkedWithCompletedUnbilled": [
+ {
+ "site": "30094",
+ "checked": true,
+ "exists": true,
+ "title": "30094 Colorado Springs, CO",
+ "taskNumber": 35,
+ "status": "client_approved",
+ "currentVersion": "R00",
+ "billedVersions": [],
+ "completedUnbilledVersions": [
+ "R00"
+ ],
+ "activeRevision": "—",
+ "activeStatus": "—",
+ "r00Billed": false
+ },
+ {
+ "site": "30096",
+ "checked": true,
+ "exists": true,
+ "title": "30096 Eerie, CO",
+ "taskNumber": 36,
+ "status": "not_started",
+ "currentVersion": "R02",
+ "billedVersions": [
+ "R00: INV-2026-006 (sent)"
+ ],
+ "completedUnbilledVersions": [
+ "R01"
+ ],
+ "activeRevision": "R02",
+ "activeStatus": "not_started",
+ "r00Billed": true
+ },
+ {
+ "site": "30261",
+ "checked": true,
+ "exists": true,
+ "title": "30261 NSA Clovis NM",
+ "taskNumber": 70,
+ "status": "client_approved",
+ "currentVersion": "R00",
+ "billedVersions": [],
+ "completedUnbilledVersions": [
+ "R00"
+ ],
+ "activeRevision": "—",
+ "activeStatus": "—",
+ "r00Billed": false
+ },
+ {
+ "site": "68720",
+ "checked": true,
+ "exists": true,
+ "title": "68720 NSA Lancaster PA",
+ "taskNumber": 74,
+ "status": "client_approved",
+ "currentVersion": "R00",
+ "billedVersions": [],
+ "completedUnbilledVersions": [
+ "R00"
+ ],
+ "activeRevision": "—",
+ "activeStatus": "—",
+ "r00Billed": false
+ },
+ {
+ "site": "68721",
+ "checked": true,
+ "exists": true,
+ "title": "68721 NSA Mechanicsburg PA",
+ "taskNumber": 48,
+ "status": "client_approved",
+ "currentVersion": "R02",
+ "billedVersions": [
+ "R00: INV-2026-006 (sent)",
+ "R01: INV-2026-006 (sent)"
+ ],
+ "completedUnbilledVersions": [
+ "R02"
+ ],
+ "activeRevision": "R02",
+ "activeStatus": "client_approved",
+ "r00Billed": true
+ },
+ {
+ "site": "68811",
+ "checked": true,
+ "exists": true,
+ "title": "68811 Kelso, WA",
+ "taskNumber": 52,
+ "status": "not_started",
+ "currentVersion": "R02",
+ "billedVersions": [
+ "R00: INV-2026-006 (sent)"
+ ],
+ "completedUnbilledVersions": [
+ "R01"
+ ],
+ "activeRevision": "R02",
+ "activeStatus": "not_started",
+ "r00Billed": true
+ }
+ ],
+ "checkedWithActiveRevision": [
+ {
+ "site": "00286",
+ "checked": true,
+ "exists": true,
+ "title": "00286 Riverside, CA",
+ "taskNumber": 31,
+ "status": "not_started",
+ "currentVersion": "R01",
+ "billedVersions": [
+ "R00: INV-2026-006 (sent)"
+ ],
+ "completedUnbilledVersions": [],
+ "activeRevision": "R01",
+ "activeStatus": "not_started",
+ "r00Billed": true
+ },
+ {
+ "site": "30055",
+ "checked": true,
+ "exists": true,
+ "title": "30055 Moreno Valley, CA",
+ "taskNumber": 33,
+ "status": "client_review",
+ "currentVersion": "R01",
+ "billedVersions": [
+ "R00: INV-2026-006 (sent)"
+ ],
+ "completedUnbilledVersions": [],
+ "activeRevision": "R01",
+ "activeStatus": "client_review",
+ "r00Billed": true
+ },
+ {
+ "site": "30096",
+ "checked": true,
+ "exists": true,
+ "title": "30096 Eerie, CO",
+ "taskNumber": 36,
+ "status": "not_started",
+ "currentVersion": "R02",
+ "billedVersions": [
+ "R00: INV-2026-006 (sent)"
+ ],
+ "completedUnbilledVersions": [
+ "R01"
+ ],
+ "activeRevision": "R02",
+ "activeStatus": "not_started",
+ "r00Billed": true
+ },
+ {
+ "site": "68639",
+ "checked": true,
+ "exists": true,
+ "title": "68639 NSA Los Lunas NM",
+ "taskNumber": 40,
+ "status": "client_review",
+ "currentVersion": "R01",
+ "billedVersions": [
+ "R00: INV-2026-006 (sent)"
+ ],
+ "completedUnbilledVersions": [],
+ "activeRevision": "R01",
+ "activeStatus": "client_review",
+ "r00Billed": true
+ },
+ {
+ "site": "68663",
+ "checked": true,
+ "exists": true,
+ "title": "68663 NSA Oklahoma City OK",
+ "taskNumber": 41,
+ "status": "not_started",
+ "currentVersion": "R01",
+ "billedVersions": [
+ "R00: INV-2026-006 (sent)"
+ ],
+ "completedUnbilledVersions": [],
+ "activeRevision": "R01",
+ "activeStatus": "not_started",
+ "r00Billed": true
+ },
+ {
+ "site": "68664",
+ "checked": true,
+ "exists": true,
+ "title": "68664 NSA Oklahoma City OK",
+ "taskNumber": 42,
+ "status": "not_started",
+ "currentVersion": "R01",
+ "billedVersions": [
+ "R00: INV-2026-006 (sent)"
+ ],
+ "completedUnbilledVersions": [],
+ "activeRevision": "R01",
+ "activeStatus": "not_started",
+ "r00Billed": true
+ },
+ {
+ "site": "68666",
+ "checked": true,
+ "exists": true,
+ "title": "68666 NSA Oklahoma City, OK",
+ "taskNumber": 43,
+ "status": "not_started",
+ "currentVersion": "R01",
+ "billedVersions": [
+ "R00: INV-2026-006 (sent)"
+ ],
+ "completedUnbilledVersions": [],
+ "activeRevision": "R01",
+ "activeStatus": "not_started",
+ "r00Billed": true
+ },
+ {
+ "site": "68669",
+ "checked": true,
+ "exists": true,
+ "title": "68669 NSA Oklahoma City OK",
+ "taskNumber": 44,
+ "status": "not_started",
+ "currentVersion": "R01",
+ "billedVersions": [
+ "R00: INV-2026-006 (sent)"
+ ],
+ "completedUnbilledVersions": [],
+ "activeRevision": "R01",
+ "activeStatus": "not_started",
+ "r00Billed": true
+ },
+ {
+ "site": "68670",
+ "checked": true,
+ "exists": true,
+ "title": "68670 NSA Oklahoma City OK",
+ "taskNumber": 45,
+ "status": "client_review",
+ "currentVersion": "R02",
+ "billedVersions": [
+ "R00: INV-2026-006 (sent)",
+ "R01: INV-2026-006 (sent)"
+ ],
+ "completedUnbilledVersions": [],
+ "activeRevision": "R02",
+ "activeStatus": "client_review",
+ "r00Billed": true
+ },
+ {
+ "site": "68671",
+ "checked": true,
+ "exists": true,
+ "title": "68671 NSA Oklahoma City OK",
+ "taskNumber": 46,
+ "status": "client_review",
+ "currentVersion": "R01",
+ "billedVersions": [
+ "R00: INV-2026-006 (sent)"
+ ],
+ "completedUnbilledVersions": [],
+ "activeRevision": "R01",
+ "activeStatus": "client_review",
+ "r00Billed": true
+ },
+ {
+ "site": "68673",
+ "checked": true,
+ "exists": true,
+ "title": "68673 NSA Oklahoma City OK",
+ "taskNumber": 47,
+ "status": "client_review",
+ "currentVersion": "R01",
+ "billedVersions": [
+ "R00: INV-2026-006 (sent)"
+ ],
+ "completedUnbilledVersions": [],
+ "activeRevision": "R01",
+ "activeStatus": "client_review",
+ "r00Billed": true
+ },
+ {
+ "site": "68721",
+ "checked": true,
+ "exists": true,
+ "title": "68721 NSA Mechanicsburg PA",
+ "taskNumber": 48,
+ "status": "client_approved",
+ "currentVersion": "R02",
+ "billedVersions": [
+ "R00: INV-2026-006 (sent)",
+ "R01: INV-2026-006 (sent)"
+ ],
+ "completedUnbilledVersions": [
+ "R02"
+ ],
+ "activeRevision": "R02",
+ "activeStatus": "client_approved",
+ "r00Billed": true
+ },
+ {
+ "site": "68808",
+ "checked": true,
+ "exists": true,
+ "title": "68808 Camas, WA",
+ "taskNumber": 49,
+ "status": "not_started",
+ "currentVersion": "R01",
+ "billedVersions": [
+ "R00: INV-2026-006 (sent)"
+ ],
+ "completedUnbilledVersions": [],
+ "activeRevision": "R01",
+ "activeStatus": "not_started",
+ "r00Billed": true
+ },
+ {
+ "site": "68809",
+ "checked": true,
+ "exists": true,
+ "title": "68809 Centralia, WA",
+ "taskNumber": 50,
+ "status": "not_started",
+ "currentVersion": "R02",
+ "billedVersions": [
+ "R00: INV-2026-006 (sent)",
+ "R01: INV-2026-006 (sent)"
+ ],
+ "completedUnbilledVersions": [],
+ "activeRevision": "R02",
+ "activeStatus": "not_started",
+ "r00Billed": true
+ },
+ {
+ "site": "68810",
+ "checked": true,
+ "exists": true,
+ "title": "68810 Chehalis, WA",
+ "taskNumber": 51,
+ "status": "not_started",
+ "currentVersion": "R02",
+ "billedVersions": [
+ "R00: INV-2026-006 (sent)",
+ "R01: INV-2026-006 (sent)"
+ ],
+ "completedUnbilledVersions": [],
+ "activeRevision": "R02",
+ "activeStatus": "not_started",
+ "r00Billed": true
+ },
+ {
+ "site": "68811",
+ "checked": true,
+ "exists": true,
+ "title": "68811 Kelso, WA",
+ "taskNumber": 52,
+ "status": "not_started",
+ "currentVersion": "R02",
+ "billedVersions": [
+ "R00: INV-2026-006 (sent)"
+ ],
+ "completedUnbilledVersions": [
+ "R01"
+ ],
+ "activeRevision": "R02",
+ "activeStatus": "not_started",
+ "r00Billed": true
+ }
+ ],
+ "result": [
+ {
+ "site": "00278",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "00279",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "00281",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "00282",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "00284",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "00285",
+ "checked": false,
+ "exists": true,
+ "title": "00285 NSA Moreno Valley CA",
+ "taskNumber": 65,
+ "status": "not_started",
+ "currentVersion": "R00",
+ "billedVersions": [],
+ "completedUnbilledVersions": [],
+ "activeRevision": "—",
+ "activeStatus": "—",
+ "r00Billed": false
+ },
+ {
+ "site": "00286",
+ "checked": true,
+ "exists": true,
+ "title": "00286 Riverside, CA",
+ "taskNumber": 31,
+ "status": "not_started",
+ "currentVersion": "R01",
+ "billedVersions": [
+ "R00: INV-2026-006 (sent)"
+ ],
+ "completedUnbilledVersions": [],
+ "activeRevision": "R01",
+ "activeStatus": "not_started",
+ "r00Billed": true
+ },
+ {
+ "site": "00290",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "00291",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "30049",
+ "checked": false,
+ "exists": true,
+ "title": "30049 Elk Grove, CA",
+ "taskNumber": 32,
+ "status": "not_started",
+ "currentVersion": "R00",
+ "billedVersions": [],
+ "completedUnbilledVersions": [],
+ "activeRevision": "—",
+ "activeStatus": "—",
+ "r00Billed": false
+ },
+ {
+ "site": "30051",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "30054",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "30055",
+ "checked": true,
+ "exists": true,
+ "title": "30055 Moreno Valley, CA",
+ "taskNumber": 33,
+ "status": "client_review",
+ "currentVersion": "R01",
+ "billedVersions": [
+ "R00: INV-2026-006 (sent)"
+ ],
+ "completedUnbilledVersions": [],
+ "activeRevision": "R01",
+ "activeStatus": "client_review",
+ "r00Billed": true
+ },
+ {
+ "site": "30056",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "30058",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "30060",
+ "checked": false,
+ "exists": true,
+ "title": "30060 NSA Palmdale CA",
+ "taskNumber": 66,
+ "status": "not_started",
+ "currentVersion": "R00",
+ "billedVersions": [],
+ "completedUnbilledVersions": [],
+ "activeRevision": "—",
+ "activeStatus": "—",
+ "r00Billed": false
+ },
+ {
+ "site": "30061",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "30062",
+ "checked": true,
+ "exists": true,
+ "title": "30062 Riverside, CA",
+ "taskNumber": 34,
+ "status": "invoiced",
+ "currentVersion": "R00",
+ "billedVersions": [
+ "R00: INV-2026-006 (sent)"
+ ],
+ "completedUnbilledVersions": [],
+ "activeRevision": "—",
+ "activeStatus": "—",
+ "r00Billed": true
+ },
+ {
+ "site": "30063",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "30068",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "30091",
+ "checked": false,
+ "exists": true,
+ "title": "30091 NSA Colorado Springs CO",
+ "taskNumber": 67,
+ "status": "not_started",
+ "currentVersion": "R00",
+ "billedVersions": [],
+ "completedUnbilledVersions": [],
+ "activeRevision": "—",
+ "activeStatus": "—",
+ "r00Billed": false
+ },
+ {
+ "site": "30094",
+ "checked": true,
+ "exists": true,
+ "title": "30094 Colorado Springs, CO",
+ "taskNumber": 35,
+ "status": "client_approved",
+ "currentVersion": "R00",
+ "billedVersions": [],
+ "completedUnbilledVersions": [
+ "R00"
+ ],
+ "activeRevision": "—",
+ "activeStatus": "—",
+ "r00Billed": false
+ },
+ {
+ "site": "30095",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "30096",
+ "checked": true,
+ "exists": true,
+ "title": "30096 Eerie, CO",
+ "taskNumber": 36,
+ "status": "not_started",
+ "currentVersion": "R02",
+ "billedVersions": [
+ "R00: INV-2026-006 (sent)"
+ ],
+ "completedUnbilledVersions": [
+ "R01"
+ ],
+ "activeRevision": "R02",
+ "activeStatus": "not_started",
+ "r00Billed": true
+ },
+ {
+ "site": "30098",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "30101",
+ "checked": false,
+ "exists": true,
+ "title": "30101 Monument, CO",
+ "taskNumber": 37,
+ "status": "on_hold",
+ "currentVersion": "R00",
+ "billedVersions": [],
+ "completedUnbilledVersions": [],
+ "activeRevision": "—",
+ "activeStatus": "—",
+ "r00Billed": false
+ },
+ {
+ "site": "30210",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "30211",
+ "checked": false,
+ "exists": true,
+ "title": "30211 NSA Post Falls ID",
+ "taskNumber": 68,
+ "status": "not_started",
+ "currentVersion": "R00",
+ "billedVersions": [],
+ "completedUnbilledVersions": [],
+ "activeRevision": "—",
+ "activeStatus": "—",
+ "r00Billed": false
+ },
+ {
+ "site": "30212",
+ "checked": false,
+ "exists": true,
+ "title": "30212 NSA Sandpoint ID",
+ "taskNumber": 69,
+ "status": "not_started",
+ "currentVersion": "R00",
+ "billedVersions": [],
+ "completedUnbilledVersions": [],
+ "activeRevision": "—",
+ "activeStatus": "—",
+ "r00Billed": false
+ },
+ {
+ "site": "30258",
+ "checked": true,
+ "exists": true,
+ "title": "30258 Lebanon, NH",
+ "taskNumber": 38,
+ "status": "invoiced",
+ "currentVersion": "R00",
+ "billedVersions": [
+ "R00: INV-2026-006 (sent)"
+ ],
+ "completedUnbilledVersions": [],
+ "activeRevision": "—",
+ "activeStatus": "—",
+ "r00Billed": true
+ },
+ {
+ "site": "30259",
+ "checked": true,
+ "exists": true,
+ "title": "30259 Hamburg, NJ",
+ "taskNumber": 39,
+ "status": "invoiced",
+ "currentVersion": "R00",
+ "billedVersions": [
+ "R00: INV-2026-006 (sent)"
+ ],
+ "completedUnbilledVersions": [],
+ "activeRevision": "—",
+ "activeStatus": "—",
+ "r00Billed": true
+ },
+ {
+ "site": "30261",
+ "checked": true,
+ "exists": true,
+ "title": "30261 NSA Clovis NM",
+ "taskNumber": 70,
+ "status": "client_approved",
+ "currentVersion": "R00",
+ "billedVersions": [],
+ "completedUnbilledVersions": [
+ "R00"
+ ],
+ "activeRevision": "—",
+ "activeStatus": "—",
+ "r00Billed": false
+ },
+ {
+ "site": "30262",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "30319",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "30342",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "00288",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "30344",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "30345",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "30348",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "30349",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "30350",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "30355",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "30357",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "30407",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "68175",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "30412",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "30413",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "68215",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "68216",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "30437",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "68220",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "68432",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "68452",
+ "checked": false,
+ "exists": true,
+ "title": "68452 NSA Houston TX",
+ "taskNumber": 71,
+ "status": "not_started",
+ "currentVersion": "R00",
+ "billedVersions": [],
+ "completedUnbilledVersions": [],
+ "activeRevision": "—",
+ "activeStatus": "—",
+ "r00Billed": false
+ },
+ {
+ "site": "68453",
+ "checked": false,
+ "exists": true,
+ "title": "68453 NSA Katy TX",
+ "taskNumber": 72,
+ "status": "not_started",
+ "currentVersion": "R00",
+ "billedVersions": [],
+ "completedUnbilledVersions": [],
+ "activeRevision": "—",
+ "activeStatus": "—",
+ "r00Billed": false
+ },
+ {
+ "site": "68454",
+ "checked": false,
+ "exists": true,
+ "title": "68454 NSA Katy TX",
+ "taskNumber": 73,
+ "status": "not_started",
+ "currentVersion": "R00",
+ "billedVersions": [],
+ "completedUnbilledVersions": [],
+ "activeRevision": "—",
+ "activeStatus": "—",
+ "r00Billed": false
+ },
+ {
+ "site": "68516",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "68617",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "68620",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "68631",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "68636",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "68637",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "68638",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "68639",
+ "checked": true,
+ "exists": true,
+ "title": "68639 NSA Los Lunas NM",
+ "taskNumber": 40,
+ "status": "client_review",
+ "currentVersion": "R01",
+ "billedVersions": [
+ "R00: INV-2026-006 (sent)"
+ ],
+ "completedUnbilledVersions": [],
+ "activeRevision": "R01",
+ "activeStatus": "client_review",
+ "r00Billed": true
+ },
+ {
+ "site": "68663",
+ "checked": true,
+ "exists": true,
+ "title": "68663 NSA Oklahoma City OK",
+ "taskNumber": 41,
+ "status": "not_started",
+ "currentVersion": "R01",
+ "billedVersions": [
+ "R00: INV-2026-006 (sent)"
+ ],
+ "completedUnbilledVersions": [],
+ "activeRevision": "R01",
+ "activeStatus": "not_started",
+ "r00Billed": true
+ },
+ {
+ "site": "68664",
+ "checked": true,
+ "exists": true,
+ "title": "68664 NSA Oklahoma City OK",
+ "taskNumber": 42,
+ "status": "not_started",
+ "currentVersion": "R01",
+ "billedVersions": [
+ "R00: INV-2026-006 (sent)"
+ ],
+ "completedUnbilledVersions": [],
+ "activeRevision": "R01",
+ "activeStatus": "not_started",
+ "r00Billed": true
+ },
+ {
+ "site": "68666",
+ "checked": true,
+ "exists": true,
+ "title": "68666 NSA Oklahoma City, OK",
+ "taskNumber": 43,
+ "status": "not_started",
+ "currentVersion": "R01",
+ "billedVersions": [
+ "R00: INV-2026-006 (sent)"
+ ],
+ "completedUnbilledVersions": [],
+ "activeRevision": "R01",
+ "activeStatus": "not_started",
+ "r00Billed": true
+ },
+ {
+ "site": "68669",
+ "checked": true,
+ "exists": true,
+ "title": "68669 NSA Oklahoma City OK",
+ "taskNumber": 44,
+ "status": "not_started",
+ "currentVersion": "R01",
+ "billedVersions": [
+ "R00: INV-2026-006 (sent)"
+ ],
+ "completedUnbilledVersions": [],
+ "activeRevision": "R01",
+ "activeStatus": "not_started",
+ "r00Billed": true
+ },
+ {
+ "site": "68670",
+ "checked": true,
+ "exists": true,
+ "title": "68670 NSA Oklahoma City OK",
+ "taskNumber": 45,
+ "status": "client_review",
+ "currentVersion": "R02",
+ "billedVersions": [
+ "R00: INV-2026-006 (sent)",
+ "R01: INV-2026-006 (sent)"
+ ],
+ "completedUnbilledVersions": [],
+ "activeRevision": "R02",
+ "activeStatus": "client_review",
+ "r00Billed": true
+ },
+ {
+ "site": "68671",
+ "checked": true,
+ "exists": true,
+ "title": "68671 NSA Oklahoma City OK",
+ "taskNumber": 46,
+ "status": "client_review",
+ "currentVersion": "R01",
+ "billedVersions": [
+ "R00: INV-2026-006 (sent)"
+ ],
+ "completedUnbilledVersions": [],
+ "activeRevision": "R01",
+ "activeStatus": "client_review",
+ "r00Billed": true
+ },
+ {
+ "site": "68673",
+ "checked": true,
+ "exists": true,
+ "title": "68673 NSA Oklahoma City OK",
+ "taskNumber": 47,
+ "status": "client_review",
+ "currentVersion": "R01",
+ "billedVersions": [
+ "R00: INV-2026-006 (sent)"
+ ],
+ "completedUnbilledVersions": [],
+ "activeRevision": "R01",
+ "activeStatus": "client_review",
+ "r00Billed": true
+ },
+ {
+ "site": "68715",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "68720",
+ "checked": true,
+ "exists": true,
+ "title": "68720 NSA Lancaster PA",
+ "taskNumber": 74,
+ "status": "client_approved",
+ "currentVersion": "R00",
+ "billedVersions": [],
+ "completedUnbilledVersions": [
+ "R00"
+ ],
+ "activeRevision": "—",
+ "activeStatus": "—",
+ "r00Billed": false
+ },
+ {
+ "site": "68721",
+ "checked": true,
+ "exists": true,
+ "title": "68721 NSA Mechanicsburg PA",
+ "taskNumber": 48,
+ "status": "client_approved",
+ "currentVersion": "R02",
+ "billedVersions": [
+ "R00: INV-2026-006 (sent)",
+ "R01: INV-2026-006 (sent)"
+ ],
+ "completedUnbilledVersions": [
+ "R02"
+ ],
+ "activeRevision": "R02",
+ "activeStatus": "client_approved",
+ "r00Billed": true
+ },
+ {
+ "site": "68723",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "68724",
+ "checked": true,
+ "exists": true,
+ "title": "68724 NSA York PA",
+ "taskNumber": 75,
+ "status": "invoiced",
+ "currentVersion": "R00",
+ "billedVersions": [
+ "R00: INV-2026-006 (sent)"
+ ],
+ "completedUnbilledVersions": [],
+ "activeRevision": "—",
+ "activeStatus": "—",
+ "r00Billed": true
+ },
+ {
+ "site": "68751",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "68752",
+ "checked": true,
+ "exists": true,
+ "title": "68752 NSA Brownsville TX",
+ "taskNumber": 76,
+ "status": "invoiced",
+ "currentVersion": "R00",
+ "billedVersions": [
+ "R00: INV-2026-006 (sent)"
+ ],
+ "completedUnbilledVersions": [],
+ "activeRevision": "—",
+ "activeStatus": "—",
+ "r00Billed": true
+ },
+ {
+ "site": "68753",
+ "checked": true,
+ "exists": true,
+ "title": "68753 NSA Brownsville TX",
+ "taskNumber": 77,
+ "status": "invoiced",
+ "currentVersion": "R00",
+ "billedVersions": [
+ "R00: INV-2026-006 (sent)"
+ ],
+ "completedUnbilledVersions": [],
+ "activeRevision": "—",
+ "activeStatus": "—",
+ "r00Billed": true
+ },
+ {
+ "site": "68754",
+ "checked": false,
+ "exists": true,
+ "title": "68754 NSA Brownsville TX",
+ "taskNumber": 78,
+ "status": "in_progress",
+ "currentVersion": "R00",
+ "billedVersions": [],
+ "completedUnbilledVersions": [],
+ "activeRevision": "—",
+ "activeStatus": "—",
+ "r00Billed": false
+ },
+ {
+ "site": "68755",
+ "checked": false,
+ "exists": true,
+ "title": "68755 NSA Brownsville TX",
+ "taskNumber": 79,
+ "status": "not_started",
+ "currentVersion": "R00",
+ "billedVersions": [],
+ "completedUnbilledVersions": [],
+ "activeRevision": "—",
+ "activeStatus": "—",
+ "r00Billed": false
+ },
+ {
+ "site": "68756",
+ "checked": false,
+ "exists": true,
+ "title": "68756 NSA Brownsville TX",
+ "taskNumber": 80,
+ "status": "in_progress",
+ "currentVersion": "R00",
+ "billedVersions": [],
+ "completedUnbilledVersions": [],
+ "activeRevision": "—",
+ "activeStatus": "—",
+ "r00Billed": false
+ },
+ {
+ "site": "68758",
+ "checked": true,
+ "exists": true,
+ "title": "68758 NSA Brownsville TX",
+ "taskNumber": 81,
+ "status": "invoiced",
+ "currentVersion": "R00",
+ "billedVersions": [
+ "R00: INV-2026-006 (sent)"
+ ],
+ "completedUnbilledVersions": [],
+ "activeRevision": "—",
+ "activeStatus": "—",
+ "r00Billed": true
+ },
+ {
+ "site": "68776",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "68777",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "68778",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "68779",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "68780",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "68781",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "68782",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "68784",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "68785",
+ "checked": false,
+ "exists": false
+ },
+ {
+ "site": "68808",
+ "checked": true,
+ "exists": true,
+ "title": "68808 Camas, WA",
+ "taskNumber": 49,
+ "status": "not_started",
+ "currentVersion": "R01",
+ "billedVersions": [
+ "R00: INV-2026-006 (sent)"
+ ],
+ "completedUnbilledVersions": [],
+ "activeRevision": "R01",
+ "activeStatus": "not_started",
+ "r00Billed": true
+ },
+ {
+ "site": "68809",
+ "checked": true,
+ "exists": true,
+ "title": "68809 Centralia, WA",
+ "taskNumber": 50,
+ "status": "not_started",
+ "currentVersion": "R02",
+ "billedVersions": [
+ "R00: INV-2026-006 (sent)",
+ "R01: INV-2026-006 (sent)"
+ ],
+ "completedUnbilledVersions": [],
+ "activeRevision": "R02",
+ "activeStatus": "not_started",
+ "r00Billed": true
+ },
+ {
+ "site": "68810",
+ "checked": true,
+ "exists": true,
+ "title": "68810 Chehalis, WA",
+ "taskNumber": 51,
+ "status": "not_started",
+ "currentVersion": "R02",
+ "billedVersions": [
+ "R00: INV-2026-006 (sent)",
+ "R01: INV-2026-006 (sent)"
+ ],
+ "completedUnbilledVersions": [],
+ "activeRevision": "R02",
+ "activeStatus": "not_started",
+ "r00Billed": true
+ },
+ {
+ "site": "68811",
+ "checked": true,
+ "exists": true,
+ "title": "68811 Kelso, WA",
+ "taskNumber": 52,
+ "status": "not_started",
+ "currentVersion": "R02",
+ "billedVersions": [
+ "R00: INV-2026-006 (sent)"
+ ],
+ "completedUnbilledVersions": [
+ "R01"
+ ],
+ "activeRevision": "R02",
+ "activeStatus": "not_started",
+ "r00Billed": true
+ }
+ ]
+}
\ No newline at end of file
diff --git a/docs/invoice-match-audit-2026-06-05.md b/docs/invoice-match-audit-2026-06-05.md
new file mode 100644
index 0000000..6b4a749
--- /dev/null
+++ b/docs/invoice-match-audit-2026-06-05.md
@@ -0,0 +1,55 @@
+# Invoice Match Audit
+
+Date: `2026-06-05`
+
+Definition used: your checked sites mean `R00` was completed. This audit compares that list against live client invoice data.
+
+- Checked sites: 26
+- Sites found in DB: 39
+- Checked sites with R00 complete but not billed: 3
+- Checked sites with completed unbilled versions: 6
+- Unchecked sites that are already billed: 0
+
+## Main Finding
+
+Client invoicing appears aligned with your checked list overall: there are **no unchecked sites currently billed** from this list.
+
+## Checked But R00 Not Billed Yet
+
+- `30094` 30094 Colorado Springs, CO -> R00, status `client_approved`
+- `30261` 30261 NSA Clovis NM -> R00, status `client_approved`
+- `68720` 68720 NSA Lancaster PA -> R00, status `client_approved`
+
+## Completed Versions Still Unbilled
+
+- `30094` 30094 Colorado Springs, CO -> unbilled: R00; billed: —; current R00 (client_approved)
+- `30096` 30096 Eerie, CO -> unbilled: R01; billed: R00: INV-2026-006 (sent); current R02 (not_started)
+- `30261` 30261 NSA Clovis NM -> unbilled: R00; billed: —; current R00 (client_approved)
+- `68720` 68720 NSA Lancaster PA -> unbilled: R00; billed: —; current R00 (client_approved)
+- `68721` 68721 NSA Mechanicsburg PA -> unbilled: R02; billed: R00: INV-2026-006 (sent) | R01: INV-2026-006 (sent); current R02 (client_approved)
+- `68811` 68811 Kelso, WA -> unbilled: R01; billed: R00: INV-2026-006 (sent); current R02 (not_started)
+
+## Checked Sites With Active Revisions
+
+| Site | Title | Current | Status | Billed Versions | Completed Unbilled |
+|---|---|---|---|---|---|
+| 00286 | 00286 Riverside, CA | R01 | not_started | R00: INV-2026-006 (sent) | — |
+| 30055 | 30055 Moreno Valley, CA | R01 | client_review | R00: INV-2026-006 (sent) | — |
+| 30096 | 30096 Eerie, CO | R02 | not_started | R00: INV-2026-006 (sent) | R01 |
+| 68639 | 68639 NSA Los Lunas NM | R01 | client_review | R00: INV-2026-006 (sent) | — |
+| 68663 | 68663 NSA Oklahoma City OK | R01 | not_started | R00: INV-2026-006 (sent) | — |
+| 68664 | 68664 NSA Oklahoma City OK | R01 | not_started | R00: INV-2026-006 (sent) | — |
+| 68666 | 68666 NSA Oklahoma City, OK | R01 | not_started | R00: INV-2026-006 (sent) | — |
+| 68669 | 68669 NSA Oklahoma City OK | R01 | not_started | R00: INV-2026-006 (sent) | — |
+| 68670 | 68670 NSA Oklahoma City OK | R02 | client_review | R00: INV-2026-006 (sent) R01: INV-2026-006 (sent) | — |
+| 68671 | 68671 NSA Oklahoma City OK | R01 | client_review | R00: INV-2026-006 (sent) | — |
+| 68673 | 68673 NSA Oklahoma City OK | R01 | client_review | R00: INV-2026-006 (sent) | — |
+| 68721 | 68721 NSA Mechanicsburg PA | R02 | client_approved | R00: INV-2026-006 (sent) R01: INV-2026-006 (sent) | R02 |
+| 68808 | 68808 Camas, WA | R01 | not_started | R00: INV-2026-006 (sent) | — |
+| 68809 | 68809 Centralia, WA | R02 | not_started | R00: INV-2026-006 (sent) R01: INV-2026-006 (sent) | — |
+| 68810 | 68810 Chehalis, WA | R02 | not_started | R00: INV-2026-006 (sent) R01: INV-2026-006 (sent) | — |
+| 68811 | 68811 Kelso, WA | R02 | not_started | R00: INV-2026-006 (sent) | R01 |
+
+## Unchecked But Already Billed
+
+- None
\ No newline at end of file
diff --git a/docs/site-audit-2026-06-04.json b/docs/site-audit-2026-06-04.json
new file mode 100644
index 0000000..abf5040
--- /dev/null
+++ b/docs/site-audit-2026-06-04.json
@@ -0,0 +1,1340 @@
+{
+ "rows": [
+ {
+ "number": "00278",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "00279",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "00281",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "00282",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "00284",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "00285",
+ "exists": true,
+ "title": "00285 NSA Moreno Valley CA",
+ "internalTaskNumber": 65,
+ "status": "not_started",
+ "currentVersion": "R00",
+ "markedComplete": false,
+ "dbCompleted": false,
+ "match": true,
+ "r00Billed": false,
+ "r00BillDetails": "",
+ "billedRevisions": [],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R00",
+ "activeVersionStatus": "not_started",
+ "versionSummary": "R00: not_started"
+ },
+ {
+ "number": "00286",
+ "exists": true,
+ "title": "00286 Riverside, CA",
+ "internalTaskNumber": 31,
+ "status": "not_started",
+ "currentVersion": "R01",
+ "markedComplete": true,
+ "dbCompleted": false,
+ "match": false,
+ "r00Billed": true,
+ "r00BillDetails": "INV-2026-006 (sent)",
+ "billedRevisions": [],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R01",
+ "activeVersionStatus": "not_started",
+ "versionSummary": "R00: completed / billed; R01: not_started"
+ },
+ {
+ "number": "00290",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "00291",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "30049",
+ "exists": true,
+ "title": "30049 Elk Grove, CA",
+ "internalTaskNumber": 32,
+ "status": "not_started",
+ "currentVersion": "R00",
+ "markedComplete": false,
+ "dbCompleted": false,
+ "match": true,
+ "r00Billed": false,
+ "r00BillDetails": "",
+ "billedRevisions": [],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R00",
+ "activeVersionStatus": "not_started",
+ "versionSummary": "R00: not_started"
+ },
+ {
+ "number": "30051",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "30054",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "30055",
+ "exists": true,
+ "title": "30055 Moreno Valley, CA",
+ "internalTaskNumber": 33,
+ "status": "client_review",
+ "currentVersion": "R01",
+ "markedComplete": true,
+ "dbCompleted": false,
+ "match": false,
+ "r00Billed": true,
+ "r00BillDetails": "INV-2026-006 (sent)",
+ "billedRevisions": [],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R01",
+ "activeVersionStatus": "client_review",
+ "versionSummary": "R00: completed / billed; R01: client_review"
+ },
+ {
+ "number": "30056",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "30058",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "30060",
+ "exists": true,
+ "title": "30060 NSA Palmdale CA",
+ "internalTaskNumber": 66,
+ "status": "not_started",
+ "currentVersion": "R00",
+ "markedComplete": false,
+ "dbCompleted": false,
+ "match": true,
+ "r00Billed": false,
+ "r00BillDetails": "",
+ "billedRevisions": [],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R00",
+ "activeVersionStatus": "not_started",
+ "versionSummary": "R00: not_started"
+ },
+ {
+ "number": "30061",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "30062",
+ "exists": true,
+ "title": "30062 Riverside, CA",
+ "internalTaskNumber": 34,
+ "status": "invoiced",
+ "currentVersion": "R00",
+ "markedComplete": true,
+ "dbCompleted": true,
+ "match": true,
+ "r00Billed": true,
+ "r00BillDetails": "INV-2026-006 (sent)",
+ "billedRevisions": [],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R00",
+ "activeVersionStatus": "invoiced",
+ "versionSummary": "R00: invoiced / billed"
+ },
+ {
+ "number": "30063",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "30068",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "30091",
+ "exists": true,
+ "title": "30091 NSA Colorado Springs CO",
+ "internalTaskNumber": 67,
+ "status": "not_started",
+ "currentVersion": "R00",
+ "markedComplete": false,
+ "dbCompleted": false,
+ "match": true,
+ "r00Billed": false,
+ "r00BillDetails": "",
+ "billedRevisions": [],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R00",
+ "activeVersionStatus": "not_started",
+ "versionSummary": "R00: not_started"
+ },
+ {
+ "number": "30094",
+ "exists": true,
+ "title": "30094 Colorado Springs, CO",
+ "internalTaskNumber": 35,
+ "status": "client_approved",
+ "currentVersion": "R00",
+ "markedComplete": true,
+ "dbCompleted": true,
+ "match": true,
+ "r00Billed": false,
+ "r00BillDetails": "",
+ "billedRevisions": [],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R00",
+ "activeVersionStatus": "client_approved",
+ "versionSummary": "R00: client_approved"
+ },
+ {
+ "number": "30095",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "30096",
+ "exists": true,
+ "title": "30096 Eerie, CO",
+ "internalTaskNumber": 36,
+ "status": "not_started",
+ "currentVersion": "R02",
+ "markedComplete": true,
+ "dbCompleted": false,
+ "match": false,
+ "r00Billed": true,
+ "r00BillDetails": "INV-2026-006 (sent)",
+ "billedRevisions": [],
+ "completedUnbilledRevisions": [
+ "R01"
+ ],
+ "activeVersion": "R02",
+ "activeVersionStatus": "not_started",
+ "versionSummary": "R00: completed / billed; R01: completed; R02: not_started"
+ },
+ {
+ "number": "30098",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "30101",
+ "exists": true,
+ "title": "30101 Monument, CO",
+ "internalTaskNumber": 37,
+ "status": "on_hold",
+ "currentVersion": "R00",
+ "markedComplete": false,
+ "dbCompleted": false,
+ "match": true,
+ "r00Billed": false,
+ "r00BillDetails": "",
+ "billedRevisions": [],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R00",
+ "activeVersionStatus": "on_hold",
+ "versionSummary": "R00: on_hold"
+ },
+ {
+ "number": "30210",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "30211",
+ "exists": true,
+ "title": "30211 NSA Post Falls ID",
+ "internalTaskNumber": 68,
+ "status": "not_started",
+ "currentVersion": "R00",
+ "markedComplete": false,
+ "dbCompleted": false,
+ "match": true,
+ "r00Billed": false,
+ "r00BillDetails": "",
+ "billedRevisions": [],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R00",
+ "activeVersionStatus": "not_started",
+ "versionSummary": "R00: not_started"
+ },
+ {
+ "number": "30212",
+ "exists": true,
+ "title": "30212 NSA Sandpoint ID",
+ "internalTaskNumber": 69,
+ "status": "not_started",
+ "currentVersion": "R00",
+ "markedComplete": false,
+ "dbCompleted": false,
+ "match": true,
+ "r00Billed": false,
+ "r00BillDetails": "",
+ "billedRevisions": [],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R00",
+ "activeVersionStatus": "not_started",
+ "versionSummary": "R00: not_started"
+ },
+ {
+ "number": "30258",
+ "exists": true,
+ "title": "30258 Lebanon, NH",
+ "internalTaskNumber": 38,
+ "status": "invoiced",
+ "currentVersion": "R00",
+ "markedComplete": true,
+ "dbCompleted": true,
+ "match": true,
+ "r00Billed": true,
+ "r00BillDetails": "INV-2026-006 (sent)",
+ "billedRevisions": [],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R00",
+ "activeVersionStatus": "invoiced",
+ "versionSummary": "R00: invoiced / billed"
+ },
+ {
+ "number": "30259",
+ "exists": true,
+ "title": "30259 Hamburg, NJ",
+ "internalTaskNumber": 39,
+ "status": "invoiced",
+ "currentVersion": "R00",
+ "markedComplete": true,
+ "dbCompleted": true,
+ "match": true,
+ "r00Billed": true,
+ "r00BillDetails": "INV-2026-006 (sent)",
+ "billedRevisions": [],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R00",
+ "activeVersionStatus": "invoiced",
+ "versionSummary": "R00: invoiced / billed"
+ },
+ {
+ "number": "30261",
+ "exists": true,
+ "title": "30261 NSA Clovis NM",
+ "internalTaskNumber": 70,
+ "status": "client_approved",
+ "currentVersion": "R00",
+ "markedComplete": true,
+ "dbCompleted": true,
+ "match": true,
+ "r00Billed": false,
+ "r00BillDetails": "",
+ "billedRevisions": [],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R00",
+ "activeVersionStatus": "client_approved",
+ "versionSummary": "R00: client_approved"
+ },
+ {
+ "number": "30262",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "30319",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "30342",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "00288",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "30344",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "30345",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "30348",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "30349",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "30350",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "30355",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "30357",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "30407",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "68175",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "30412",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "30413",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "68215",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "68216",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "30437",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "68220",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "68432",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "68452",
+ "exists": true,
+ "title": "68452 NSA Houston TX",
+ "internalTaskNumber": 71,
+ "status": "not_started",
+ "currentVersion": "R00",
+ "markedComplete": false,
+ "dbCompleted": false,
+ "match": true,
+ "r00Billed": false,
+ "r00BillDetails": "",
+ "billedRevisions": [],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R00",
+ "activeVersionStatus": "not_started",
+ "versionSummary": "R00: not_started"
+ },
+ {
+ "number": "68453",
+ "exists": true,
+ "title": "68453 NSA Katy TX",
+ "internalTaskNumber": 72,
+ "status": "not_started",
+ "currentVersion": "R00",
+ "markedComplete": false,
+ "dbCompleted": false,
+ "match": true,
+ "r00Billed": false,
+ "r00BillDetails": "",
+ "billedRevisions": [],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R00",
+ "activeVersionStatus": "not_started",
+ "versionSummary": "R00: not_started"
+ },
+ {
+ "number": "68454",
+ "exists": true,
+ "title": "68454 NSA Katy TX",
+ "internalTaskNumber": 73,
+ "status": "not_started",
+ "currentVersion": "R00",
+ "markedComplete": false,
+ "dbCompleted": false,
+ "match": true,
+ "r00Billed": false,
+ "r00BillDetails": "",
+ "billedRevisions": [],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R00",
+ "activeVersionStatus": "not_started",
+ "versionSummary": "R00: not_started"
+ },
+ {
+ "number": "68516",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "68617",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "68620",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "68631",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "68636",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "68637",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "68638",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "68639",
+ "exists": true,
+ "title": "68639 NSA Los Lunas NM",
+ "internalTaskNumber": 40,
+ "status": "client_review",
+ "currentVersion": "R01",
+ "markedComplete": true,
+ "dbCompleted": false,
+ "match": false,
+ "r00Billed": true,
+ "r00BillDetails": "INV-2026-006 (sent)",
+ "billedRevisions": [],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R01",
+ "activeVersionStatus": "client_review",
+ "versionSummary": "R00: completed / billed; R01: client_review"
+ },
+ {
+ "number": "68663",
+ "exists": true,
+ "title": "68663 NSA Oklahoma City OK",
+ "internalTaskNumber": 41,
+ "status": "not_started",
+ "currentVersion": "R01",
+ "markedComplete": true,
+ "dbCompleted": false,
+ "match": false,
+ "r00Billed": true,
+ "r00BillDetails": "INV-2026-006 (sent)",
+ "billedRevisions": [],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R01",
+ "activeVersionStatus": "not_started",
+ "versionSummary": "R00: completed / billed; R01: not_started"
+ },
+ {
+ "number": "68664",
+ "exists": true,
+ "title": "68664 NSA Oklahoma City OK",
+ "internalTaskNumber": 42,
+ "status": "not_started",
+ "currentVersion": "R01",
+ "markedComplete": true,
+ "dbCompleted": false,
+ "match": false,
+ "r00Billed": true,
+ "r00BillDetails": "INV-2026-006 (sent)",
+ "billedRevisions": [],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R01",
+ "activeVersionStatus": "not_started",
+ "versionSummary": "R00: completed / billed; R01: not_started"
+ },
+ {
+ "number": "68666",
+ "exists": true,
+ "title": "68666 NSA Oklahoma City, OK",
+ "internalTaskNumber": 43,
+ "status": "not_started",
+ "currentVersion": "R01",
+ "markedComplete": true,
+ "dbCompleted": false,
+ "match": false,
+ "r00Billed": true,
+ "r00BillDetails": "INV-2026-006 (sent)",
+ "billedRevisions": [],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R01",
+ "activeVersionStatus": "not_started",
+ "versionSummary": "R00: completed / billed; R01: not_started"
+ },
+ {
+ "number": "68669",
+ "exists": true,
+ "title": "68669 NSA Oklahoma City OK",
+ "internalTaskNumber": 44,
+ "status": "not_started",
+ "currentVersion": "R01",
+ "markedComplete": true,
+ "dbCompleted": false,
+ "match": false,
+ "r00Billed": true,
+ "r00BillDetails": "INV-2026-006 (sent)",
+ "billedRevisions": [],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R01",
+ "activeVersionStatus": "not_started",
+ "versionSummary": "R00: completed / billed; R01: not_started"
+ },
+ {
+ "number": "68670",
+ "exists": true,
+ "title": "68670 NSA Oklahoma City OK",
+ "internalTaskNumber": 45,
+ "status": "not_started",
+ "currentVersion": "R02",
+ "markedComplete": true,
+ "dbCompleted": false,
+ "match": false,
+ "r00Billed": true,
+ "r00BillDetails": "INV-2026-006 (sent)",
+ "billedRevisions": [
+ "R01 [INV-2026-006 (sent)]"
+ ],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R02",
+ "activeVersionStatus": "not_started",
+ "versionSummary": "R00: completed / billed; R01: completed / billed; R02: not_started"
+ },
+ {
+ "number": "68671",
+ "exists": true,
+ "title": "68671 NSA Oklahoma City OK",
+ "internalTaskNumber": 46,
+ "status": "not_started",
+ "currentVersion": "R01",
+ "markedComplete": true,
+ "dbCompleted": false,
+ "match": false,
+ "r00Billed": true,
+ "r00BillDetails": "INV-2026-006 (sent)",
+ "billedRevisions": [],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R01",
+ "activeVersionStatus": "not_started",
+ "versionSummary": "R00: completed / billed; R01: not_started"
+ },
+ {
+ "number": "68673",
+ "exists": true,
+ "title": "68673 NSA Oklahoma City OK",
+ "internalTaskNumber": 47,
+ "status": "client_review",
+ "currentVersion": "R01",
+ "markedComplete": true,
+ "dbCompleted": false,
+ "match": false,
+ "r00Billed": true,
+ "r00BillDetails": "INV-2026-006 (sent)",
+ "billedRevisions": [],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R01",
+ "activeVersionStatus": "client_review",
+ "versionSummary": "R00: completed / billed; R01: client_review"
+ },
+ {
+ "number": "68715",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "68720",
+ "exists": true,
+ "title": "68720 NSA Lancaster PA",
+ "internalTaskNumber": 74,
+ "status": "client_approved",
+ "currentVersion": "R00",
+ "markedComplete": true,
+ "dbCompleted": true,
+ "match": true,
+ "r00Billed": false,
+ "r00BillDetails": "",
+ "billedRevisions": [],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R00",
+ "activeVersionStatus": "client_approved",
+ "versionSummary": "R00: client_approved"
+ },
+ {
+ "number": "68721",
+ "exists": true,
+ "title": "68721 NSA Mechanicsburg PA",
+ "internalTaskNumber": 48,
+ "status": "client_approved",
+ "currentVersion": "R02",
+ "markedComplete": true,
+ "dbCompleted": true,
+ "match": true,
+ "r00Billed": true,
+ "r00BillDetails": "INV-2026-006 (sent)",
+ "billedRevisions": [
+ "R01 [INV-2026-006 (sent)]"
+ ],
+ "completedUnbilledRevisions": [
+ "R02"
+ ],
+ "activeVersion": "R02",
+ "activeVersionStatus": "client_approved",
+ "versionSummary": "R00: completed / billed; R01: completed / billed; R02: client_approved"
+ },
+ {
+ "number": "68723",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "68724",
+ "exists": true,
+ "title": "68724 NSA York PA",
+ "internalTaskNumber": 75,
+ "status": "invoiced",
+ "currentVersion": "R00",
+ "markedComplete": true,
+ "dbCompleted": true,
+ "match": true,
+ "r00Billed": true,
+ "r00BillDetails": "INV-2026-006 (sent)",
+ "billedRevisions": [],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R00",
+ "activeVersionStatus": "invoiced",
+ "versionSummary": "R00: invoiced / billed"
+ },
+ {
+ "number": "68751",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "68752",
+ "exists": true,
+ "title": "68752 NSA Brownsville TX",
+ "internalTaskNumber": 76,
+ "status": "invoiced",
+ "currentVersion": "R00",
+ "markedComplete": true,
+ "dbCompleted": true,
+ "match": true,
+ "r00Billed": true,
+ "r00BillDetails": "INV-2026-006 (sent)",
+ "billedRevisions": [],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R00",
+ "activeVersionStatus": "invoiced",
+ "versionSummary": "R00: invoiced / billed"
+ },
+ {
+ "number": "68753",
+ "exists": true,
+ "title": "68753 NSA Brownsville TX",
+ "internalTaskNumber": 77,
+ "status": "invoiced",
+ "currentVersion": "R00",
+ "markedComplete": true,
+ "dbCompleted": true,
+ "match": true,
+ "r00Billed": true,
+ "r00BillDetails": "INV-2026-006 (sent)",
+ "billedRevisions": [],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R00",
+ "activeVersionStatus": "invoiced",
+ "versionSummary": "R00: invoiced / billed"
+ },
+ {
+ "number": "68754",
+ "exists": true,
+ "title": "68754 NSA Brownsville TX",
+ "internalTaskNumber": 78,
+ "status": "in_progress",
+ "currentVersion": "R00",
+ "markedComplete": false,
+ "dbCompleted": false,
+ "match": true,
+ "r00Billed": false,
+ "r00BillDetails": "",
+ "billedRevisions": [],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R00",
+ "activeVersionStatus": "in_progress",
+ "versionSummary": "R00: in_progress"
+ },
+ {
+ "number": "68755",
+ "exists": true,
+ "title": "68755 NSA Brownsville TX",
+ "internalTaskNumber": 79,
+ "status": "not_started",
+ "currentVersion": "R00",
+ "markedComplete": false,
+ "dbCompleted": false,
+ "match": true,
+ "r00Billed": false,
+ "r00BillDetails": "",
+ "billedRevisions": [],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R00",
+ "activeVersionStatus": "not_started",
+ "versionSummary": "R00: not_started"
+ },
+ {
+ "number": "68756",
+ "exists": true,
+ "title": "68756 NSA Brownsville TX",
+ "internalTaskNumber": 80,
+ "status": "in_progress",
+ "currentVersion": "R00",
+ "markedComplete": false,
+ "dbCompleted": false,
+ "match": true,
+ "r00Billed": false,
+ "r00BillDetails": "",
+ "billedRevisions": [],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R00",
+ "activeVersionStatus": "in_progress",
+ "versionSummary": "R00: in_progress"
+ },
+ {
+ "number": "68758",
+ "exists": true,
+ "title": "68758 NSA Brownsville TX",
+ "internalTaskNumber": 81,
+ "status": "invoiced",
+ "currentVersion": "R00",
+ "markedComplete": true,
+ "dbCompleted": true,
+ "match": true,
+ "r00Billed": true,
+ "r00BillDetails": "INV-2026-006 (sent)",
+ "billedRevisions": [],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R00",
+ "activeVersionStatus": "invoiced",
+ "versionSummary": "R00: invoiced / billed"
+ },
+ {
+ "number": "68776",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "68777",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "68778",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "68779",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "68780",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "68781",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "68782",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "68784",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "68785",
+ "exists": false,
+ "markedComplete": false
+ },
+ {
+ "number": "68808",
+ "exists": true,
+ "title": "68808 Camas, WA",
+ "internalTaskNumber": 49,
+ "status": "not_started",
+ "currentVersion": "R01",
+ "markedComplete": true,
+ "dbCompleted": false,
+ "match": false,
+ "r00Billed": true,
+ "r00BillDetails": "INV-2026-006 (sent)",
+ "billedRevisions": [],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R01",
+ "activeVersionStatus": "not_started",
+ "versionSummary": "R00: completed / billed; R01: not_started"
+ },
+ {
+ "number": "68809",
+ "exists": true,
+ "title": "68809 Centralia, WA",
+ "internalTaskNumber": 50,
+ "status": "not_started",
+ "currentVersion": "R02",
+ "markedComplete": true,
+ "dbCompleted": false,
+ "match": false,
+ "r00Billed": true,
+ "r00BillDetails": "INV-2026-006 (sent)",
+ "billedRevisions": [
+ "R01 [INV-2026-006 (sent)]"
+ ],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R02",
+ "activeVersionStatus": "not_started",
+ "versionSummary": "R00: completed / billed; R01: completed / billed; R02: not_started"
+ },
+ {
+ "number": "68810",
+ "exists": true,
+ "title": "68810 Chehalis, WA",
+ "internalTaskNumber": 51,
+ "status": "not_started",
+ "currentVersion": "R02",
+ "markedComplete": true,
+ "dbCompleted": false,
+ "match": false,
+ "r00Billed": true,
+ "r00BillDetails": "INV-2026-006 (sent)",
+ "billedRevisions": [
+ "R01 [INV-2026-006 (sent)]"
+ ],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R02",
+ "activeVersionStatus": "not_started",
+ "versionSummary": "R00: completed / billed; R01: completed / billed; R02: not_started"
+ },
+ {
+ "number": "68811",
+ "exists": true,
+ "title": "68811 Kelso, WA",
+ "internalTaskNumber": 52,
+ "status": "not_started",
+ "currentVersion": "R02",
+ "markedComplete": true,
+ "dbCompleted": false,
+ "match": false,
+ "r00Billed": true,
+ "r00BillDetails": "INV-2026-006 (sent)",
+ "billedRevisions": [],
+ "completedUnbilledRevisions": [
+ "R01"
+ ],
+ "activeVersion": "R02",
+ "activeVersionStatus": "not_started",
+ "versionSummary": "R00: completed / billed; R01: completed; R02: not_started"
+ }
+ ],
+ "missing": [
+ "00278",
+ "00279",
+ "00281",
+ "00282",
+ "00284",
+ "00290",
+ "00291",
+ "30051",
+ "30054",
+ "30056",
+ "30058",
+ "30061",
+ "30063",
+ "30068",
+ "30095",
+ "30098",
+ "30210",
+ "30262",
+ "30319",
+ "30342",
+ "00288",
+ "30344",
+ "30345",
+ "30348",
+ "30349",
+ "30350",
+ "30355",
+ "30357",
+ "30407",
+ "68175",
+ "30412",
+ "30413",
+ "68215",
+ "68216",
+ "30437",
+ "68220",
+ "68432",
+ "68516",
+ "68617",
+ "68620",
+ "68631",
+ "68636",
+ "68637",
+ "68638",
+ "68715",
+ "68723",
+ "68751",
+ "68776",
+ "68777",
+ "68778",
+ "68779",
+ "68780",
+ "68781",
+ "68782",
+ "68784",
+ "68785"
+ ],
+ "mismatches": [
+ {
+ "number": "00286",
+ "exists": true,
+ "title": "00286 Riverside, CA",
+ "internalTaskNumber": 31,
+ "status": "not_started",
+ "currentVersion": "R01",
+ "markedComplete": true,
+ "dbCompleted": false,
+ "match": false,
+ "r00Billed": true,
+ "r00BillDetails": "INV-2026-006 (sent)",
+ "billedRevisions": [],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R01",
+ "activeVersionStatus": "not_started",
+ "versionSummary": "R00: completed / billed; R01: not_started"
+ },
+ {
+ "number": "30055",
+ "exists": true,
+ "title": "30055 Moreno Valley, CA",
+ "internalTaskNumber": 33,
+ "status": "client_review",
+ "currentVersion": "R01",
+ "markedComplete": true,
+ "dbCompleted": false,
+ "match": false,
+ "r00Billed": true,
+ "r00BillDetails": "INV-2026-006 (sent)",
+ "billedRevisions": [],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R01",
+ "activeVersionStatus": "client_review",
+ "versionSummary": "R00: completed / billed; R01: client_review"
+ },
+ {
+ "number": "30096",
+ "exists": true,
+ "title": "30096 Eerie, CO",
+ "internalTaskNumber": 36,
+ "status": "not_started",
+ "currentVersion": "R02",
+ "markedComplete": true,
+ "dbCompleted": false,
+ "match": false,
+ "r00Billed": true,
+ "r00BillDetails": "INV-2026-006 (sent)",
+ "billedRevisions": [],
+ "completedUnbilledRevisions": [
+ "R01"
+ ],
+ "activeVersion": "R02",
+ "activeVersionStatus": "not_started",
+ "versionSummary": "R00: completed / billed; R01: completed; R02: not_started"
+ },
+ {
+ "number": "68639",
+ "exists": true,
+ "title": "68639 NSA Los Lunas NM",
+ "internalTaskNumber": 40,
+ "status": "client_review",
+ "currentVersion": "R01",
+ "markedComplete": true,
+ "dbCompleted": false,
+ "match": false,
+ "r00Billed": true,
+ "r00BillDetails": "INV-2026-006 (sent)",
+ "billedRevisions": [],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R01",
+ "activeVersionStatus": "client_review",
+ "versionSummary": "R00: completed / billed; R01: client_review"
+ },
+ {
+ "number": "68663",
+ "exists": true,
+ "title": "68663 NSA Oklahoma City OK",
+ "internalTaskNumber": 41,
+ "status": "not_started",
+ "currentVersion": "R01",
+ "markedComplete": true,
+ "dbCompleted": false,
+ "match": false,
+ "r00Billed": true,
+ "r00BillDetails": "INV-2026-006 (sent)",
+ "billedRevisions": [],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R01",
+ "activeVersionStatus": "not_started",
+ "versionSummary": "R00: completed / billed; R01: not_started"
+ },
+ {
+ "number": "68664",
+ "exists": true,
+ "title": "68664 NSA Oklahoma City OK",
+ "internalTaskNumber": 42,
+ "status": "not_started",
+ "currentVersion": "R01",
+ "markedComplete": true,
+ "dbCompleted": false,
+ "match": false,
+ "r00Billed": true,
+ "r00BillDetails": "INV-2026-006 (sent)",
+ "billedRevisions": [],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R01",
+ "activeVersionStatus": "not_started",
+ "versionSummary": "R00: completed / billed; R01: not_started"
+ },
+ {
+ "number": "68666",
+ "exists": true,
+ "title": "68666 NSA Oklahoma City, OK",
+ "internalTaskNumber": 43,
+ "status": "not_started",
+ "currentVersion": "R01",
+ "markedComplete": true,
+ "dbCompleted": false,
+ "match": false,
+ "r00Billed": true,
+ "r00BillDetails": "INV-2026-006 (sent)",
+ "billedRevisions": [],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R01",
+ "activeVersionStatus": "not_started",
+ "versionSummary": "R00: completed / billed; R01: not_started"
+ },
+ {
+ "number": "68669",
+ "exists": true,
+ "title": "68669 NSA Oklahoma City OK",
+ "internalTaskNumber": 44,
+ "status": "not_started",
+ "currentVersion": "R01",
+ "markedComplete": true,
+ "dbCompleted": false,
+ "match": false,
+ "r00Billed": true,
+ "r00BillDetails": "INV-2026-006 (sent)",
+ "billedRevisions": [],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R01",
+ "activeVersionStatus": "not_started",
+ "versionSummary": "R00: completed / billed; R01: not_started"
+ },
+ {
+ "number": "68670",
+ "exists": true,
+ "title": "68670 NSA Oklahoma City OK",
+ "internalTaskNumber": 45,
+ "status": "not_started",
+ "currentVersion": "R02",
+ "markedComplete": true,
+ "dbCompleted": false,
+ "match": false,
+ "r00Billed": true,
+ "r00BillDetails": "INV-2026-006 (sent)",
+ "billedRevisions": [
+ "R01 [INV-2026-006 (sent)]"
+ ],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R02",
+ "activeVersionStatus": "not_started",
+ "versionSummary": "R00: completed / billed; R01: completed / billed; R02: not_started"
+ },
+ {
+ "number": "68671",
+ "exists": true,
+ "title": "68671 NSA Oklahoma City OK",
+ "internalTaskNumber": 46,
+ "status": "not_started",
+ "currentVersion": "R01",
+ "markedComplete": true,
+ "dbCompleted": false,
+ "match": false,
+ "r00Billed": true,
+ "r00BillDetails": "INV-2026-006 (sent)",
+ "billedRevisions": [],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R01",
+ "activeVersionStatus": "not_started",
+ "versionSummary": "R00: completed / billed; R01: not_started"
+ },
+ {
+ "number": "68673",
+ "exists": true,
+ "title": "68673 NSA Oklahoma City OK",
+ "internalTaskNumber": 47,
+ "status": "client_review",
+ "currentVersion": "R01",
+ "markedComplete": true,
+ "dbCompleted": false,
+ "match": false,
+ "r00Billed": true,
+ "r00BillDetails": "INV-2026-006 (sent)",
+ "billedRevisions": [],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R01",
+ "activeVersionStatus": "client_review",
+ "versionSummary": "R00: completed / billed; R01: client_review"
+ },
+ {
+ "number": "68808",
+ "exists": true,
+ "title": "68808 Camas, WA",
+ "internalTaskNumber": 49,
+ "status": "not_started",
+ "currentVersion": "R01",
+ "markedComplete": true,
+ "dbCompleted": false,
+ "match": false,
+ "r00Billed": true,
+ "r00BillDetails": "INV-2026-006 (sent)",
+ "billedRevisions": [],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R01",
+ "activeVersionStatus": "not_started",
+ "versionSummary": "R00: completed / billed; R01: not_started"
+ },
+ {
+ "number": "68809",
+ "exists": true,
+ "title": "68809 Centralia, WA",
+ "internalTaskNumber": 50,
+ "status": "not_started",
+ "currentVersion": "R02",
+ "markedComplete": true,
+ "dbCompleted": false,
+ "match": false,
+ "r00Billed": true,
+ "r00BillDetails": "INV-2026-006 (sent)",
+ "billedRevisions": [
+ "R01 [INV-2026-006 (sent)]"
+ ],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R02",
+ "activeVersionStatus": "not_started",
+ "versionSummary": "R00: completed / billed; R01: completed / billed; R02: not_started"
+ },
+ {
+ "number": "68810",
+ "exists": true,
+ "title": "68810 Chehalis, WA",
+ "internalTaskNumber": 51,
+ "status": "not_started",
+ "currentVersion": "R02",
+ "markedComplete": true,
+ "dbCompleted": false,
+ "match": false,
+ "r00Billed": true,
+ "r00BillDetails": "INV-2026-006 (sent)",
+ "billedRevisions": [
+ "R01 [INV-2026-006 (sent)]"
+ ],
+ "completedUnbilledRevisions": [],
+ "activeVersion": "R02",
+ "activeVersionStatus": "not_started",
+ "versionSummary": "R00: completed / billed; R01: completed / billed; R02: not_started"
+ },
+ {
+ "number": "68811",
+ "exists": true,
+ "title": "68811 Kelso, WA",
+ "internalTaskNumber": 52,
+ "status": "not_started",
+ "currentVersion": "R02",
+ "markedComplete": true,
+ "dbCompleted": false,
+ "match": false,
+ "r00Billed": true,
+ "r00BillDetails": "INV-2026-006 (sent)",
+ "billedRevisions": [],
+ "completedUnbilledRevisions": [
+ "R01"
+ ],
+ "activeVersion": "R02",
+ "activeVersionStatus": "not_started",
+ "versionSummary": "R00: completed / billed; R01: completed; R02: not_started"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/docs/site-audit-2026-06-04.md b/docs/site-audit-2026-06-04.md
new file mode 100644
index 0000000..67f69ea
--- /dev/null
+++ b/docs/site-audit-2026-06-04.md
@@ -0,0 +1,185 @@
+# Site Completion and Billing Audit
+
+Generated: 2026-06-04T13:58:27.846Z
+
+- Listed sites: 95
+- Found in database: 39
+- Missing from database: 56
+- Checked vs DB mismatches: 15
+
+## Missing From Database
+
+- 00278
+- 00279
+- 00281
+- 00282
+- 00284
+- 00290
+- 00291
+- 30051
+- 30054
+- 30056
+- 30058
+- 30061
+- 30063
+- 30068
+- 30095
+- 30098
+- 30210
+- 30262
+- 30319
+- 30342
+- 00288
+- 30344
+- 30345
+- 30348
+- 30349
+- 30350
+- 30355
+- 30357
+- 30407
+- 68175
+- 30412
+- 30413
+- 68215
+- 68216
+- 30437
+- 68220
+- 68432
+- 68516
+- 68617
+- 68620
+- 68631
+- 68636
+- 68637
+- 68638
+- 68715
+- 68723
+- 68751
+- 68776
+- 68777
+- 68778
+- 68779
+- 68780
+- 68781
+- 68782
+- 68784
+- 68785
+
+## Checked vs Database Mismatches
+
+- 00286 00286 Riverside, CA: you marked completed, DB status is not_started
+- 30055 30055 Moreno Valley, CA: you marked completed, DB status is client_review
+- 30096 30096 Eerie, CO: you marked completed, DB status is not_started
+- 68639 68639 NSA Los Lunas NM: you marked completed, DB status is client_review
+- 68663 68663 NSA Oklahoma City OK: you marked completed, DB status is not_started
+- 68664 68664 NSA Oklahoma City OK: you marked completed, DB status is not_started
+- 68666 68666 NSA Oklahoma City, OK: you marked completed, DB status is not_started
+- 68669 68669 NSA Oklahoma City OK: you marked completed, DB status is not_started
+- 68670 68670 NSA Oklahoma City OK: you marked completed, DB status is not_started
+- 68671 68671 NSA Oklahoma City OK: you marked completed, DB status is not_started
+- 68673 68673 NSA Oklahoma City OK: you marked completed, DB status is client_review
+- 68808 68808 Camas, WA: you marked completed, DB status is not_started
+- 68809 68809 Centralia, WA: you marked completed, DB status is not_started
+- 68810 68810 Chehalis, WA: you marked completed, DB status is not_started
+- 68811 68811 Kelso, WA: you marked completed, DB status is not_started
+
+## Audit Table
+
+| Site # | DB Title | Internal # | In DB | Your Check | DB Completed | Match | Current R# | Active Status | R00 Billed | Billed Revisions | Completed Unbilled Revisions | Version Status Summary |
+|---|---|---:|---:|---:|---:|---:|---|---|---|---|---|---|
+| 00278 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 00279 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 00281 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 00282 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 00284 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 00285 | 00285 NSA Moreno Valley CA | 65 | Yes | No | No | Yes | R00 | not_started | No | — | — | R00: not_started |
+| 00286 | 00286 Riverside, CA | 31 | Yes | Yes | No | No | R01 | not_started | Yes (INV-2026-006 (sent)) | — | — | R00: completed / billed; R01: not_started |
+| 00290 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 00291 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 30049 | 30049 Elk Grove, CA | 32 | Yes | No | No | Yes | R00 | not_started | No | — | — | R00: not_started |
+| 30051 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 30054 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 30055 | 30055 Moreno Valley, CA | 33 | Yes | Yes | No | No | R01 | client_review | Yes (INV-2026-006 (sent)) | — | — | R00: completed / billed; R01: client_review |
+| 30056 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 30058 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 30060 | 30060 NSA Palmdale CA | 66 | Yes | No | No | Yes | R00 | not_started | No | — | — | R00: not_started |
+| 30061 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 30062 | 30062 Riverside, CA | 34 | Yes | Yes | Yes | Yes | R00 | invoiced | Yes (INV-2026-006 (sent)) | — | — | R00: invoiced / billed |
+| 30063 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 30068 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 30091 | 30091 NSA Colorado Springs CO | 67 | Yes | No | No | Yes | R00 | not_started | No | — | — | R00: not_started |
+| 30094 | 30094 Colorado Springs, CO | 35 | Yes | Yes | Yes | Yes | R00 | client_approved | No | — | — | R00: client_approved |
+| 30095 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 30096 | 30096 Eerie, CO | 36 | Yes | Yes | No | No | R02 | not_started | Yes (INV-2026-006 (sent)) | — | R01 | R00: completed / billed; R01: completed; R02: not_started |
+| 30098 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 30101 | 30101 Monument, CO | 37 | Yes | No | No | Yes | R00 | on_hold | No | — | — | R00: on_hold |
+| 30210 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 30211 | 30211 NSA Post Falls ID | 68 | Yes | No | No | Yes | R00 | not_started | No | — | — | R00: not_started |
+| 30212 | 30212 NSA Sandpoint ID | 69 | Yes | No | No | Yes | R00 | not_started | No | — | — | R00: not_started |
+| 30258 | 30258 Lebanon, NH | 38 | Yes | Yes | Yes | Yes | R00 | invoiced | Yes (INV-2026-006 (sent)) | — | — | R00: invoiced / billed |
+| 30259 | 30259 Hamburg, NJ | 39 | Yes | Yes | Yes | Yes | R00 | invoiced | Yes (INV-2026-006 (sent)) | — | — | R00: invoiced / billed |
+| 30261 | 30261 NSA Clovis NM | 70 | Yes | Yes | Yes | Yes | R00 | client_approved | No | — | — | R00: client_approved |
+| 30262 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 30319 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 30342 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 00288 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 30344 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 30345 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 30348 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 30349 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 30350 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 30355 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 30357 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 30407 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 68175 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 30412 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 30413 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 68215 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 68216 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 30437 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 68220 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 68432 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 68452 | 68452 NSA Houston TX | 71 | Yes | No | No | Yes | R00 | not_started | No | — | — | R00: not_started |
+| 68453 | 68453 NSA Katy TX | 72 | Yes | No | No | Yes | R00 | not_started | No | — | — | R00: not_started |
+| 68454 | 68454 NSA Katy TX | 73 | Yes | No | No | Yes | R00 | not_started | No | — | — | R00: not_started |
+| 68516 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 68617 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 68620 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 68631 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 68636 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 68637 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 68638 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 68639 | 68639 NSA Los Lunas NM | 40 | Yes | Yes | No | No | R01 | client_review | Yes (INV-2026-006 (sent)) | — | — | R00: completed / billed; R01: client_review |
+| 68663 | 68663 NSA Oklahoma City OK | 41 | Yes | Yes | No | No | R01 | not_started | Yes (INV-2026-006 (sent)) | — | — | R00: completed / billed; R01: not_started |
+| 68664 | 68664 NSA Oklahoma City OK | 42 | Yes | Yes | No | No | R01 | not_started | Yes (INV-2026-006 (sent)) | — | — | R00: completed / billed; R01: not_started |
+| 68666 | 68666 NSA Oklahoma City, OK | 43 | Yes | Yes | No | No | R01 | not_started | Yes (INV-2026-006 (sent)) | — | — | R00: completed / billed; R01: not_started |
+| 68669 | 68669 NSA Oklahoma City OK | 44 | Yes | Yes | No | No | R01 | not_started | Yes (INV-2026-006 (sent)) | — | — | R00: completed / billed; R01: not_started |
+| 68670 | 68670 NSA Oklahoma City OK | 45 | Yes | Yes | No | No | R02 | not_started | Yes (INV-2026-006 (sent)) | R01 [INV-2026-006 (sent)] | — | R00: completed / billed; R01: completed / billed; R02: not_started |
+| 68671 | 68671 NSA Oklahoma City OK | 46 | Yes | Yes | No | No | R01 | not_started | Yes (INV-2026-006 (sent)) | — | — | R00: completed / billed; R01: not_started |
+| 68673 | 68673 NSA Oklahoma City OK | 47 | Yes | Yes | No | No | R01 | client_review | Yes (INV-2026-006 (sent)) | — | — | R00: completed / billed; R01: client_review |
+| 68715 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 68720 | 68720 NSA Lancaster PA | 74 | Yes | Yes | Yes | Yes | R00 | client_approved | No | — | — | R00: client_approved |
+| 68721 | 68721 NSA Mechanicsburg PA | 48 | Yes | Yes | Yes | Yes | R02 | client_approved | Yes (INV-2026-006 (sent)) | R01 [INV-2026-006 (sent)] | R02 | R00: completed / billed; R01: completed / billed; R02: client_approved |
+| 68723 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 68724 | 68724 NSA York PA | 75 | Yes | Yes | Yes | Yes | R00 | invoiced | Yes (INV-2026-006 (sent)) | — | — | R00: invoiced / billed |
+| 68751 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 68752 | 68752 NSA Brownsville TX | 76 | Yes | Yes | Yes | Yes | R00 | invoiced | Yes (INV-2026-006 (sent)) | — | — | R00: invoiced / billed |
+| 68753 | 68753 NSA Brownsville TX | 77 | Yes | Yes | Yes | Yes | R00 | invoiced | Yes (INV-2026-006 (sent)) | — | — | R00: invoiced / billed |
+| 68754 | 68754 NSA Brownsville TX | 78 | Yes | No | No | Yes | R00 | in_progress | No | — | — | R00: in_progress |
+| 68755 | 68755 NSA Brownsville TX | 79 | Yes | No | No | Yes | R00 | not_started | No | — | — | R00: not_started |
+| 68756 | 68756 NSA Brownsville TX | 80 | Yes | No | No | Yes | R00 | in_progress | No | — | — | R00: in_progress |
+| 68758 | 68758 NSA Brownsville TX | 81 | Yes | Yes | Yes | Yes | R00 | invoiced | Yes (INV-2026-006 (sent)) | — | — | R00: invoiced / billed |
+| 68776 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 68777 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 68778 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 68779 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 68780 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 68781 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 68782 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 68784 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 68785 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
+| 68808 | 68808 Camas, WA | 49 | Yes | Yes | No | No | R01 | not_started | Yes (INV-2026-006 (sent)) | — | — | R00: completed / billed; R01: not_started |
+| 68809 | 68809 Centralia, WA | 50 | Yes | Yes | No | No | R02 | not_started | Yes (INV-2026-006 (sent)) | R01 [INV-2026-006 (sent)] | — | R00: completed / billed; R01: completed / billed; R02: not_started |
+| 68810 | 68810 Chehalis, WA | 51 | Yes | Yes | No | No | R02 | not_started | Yes (INV-2026-006 (sent)) | R01 [INV-2026-006 (sent)] | — | R00: completed / billed; R01: completed / billed; R02: not_started |
+| 68811 | 68811 Kelso, WA | 52 | Yes | Yes | No | No | R02 | not_started | Yes (INV-2026-006 (sent)) | — | R01 | R00: completed / billed; R01: completed; R02: not_started |
\ No newline at end of file
diff --git a/index.html b/index.html
index c066899..be471f2 100644
--- a/index.html
+++ b/index.html
@@ -9,6 +9,8 @@
document.documentElement.style.colorScheme = theme === 'light' ? 'light' : 'dark';
})();
+
+
diff --git a/layout.md b/layout.md
index e381e07..2fd64e6 100644
--- a/layout.md
+++ b/layout.md
@@ -15,6 +15,15 @@ This is the single source of truth for dashboard/profile visual structure and UI
- radial glow + vertical dark gradient.
- Light base token `--bg` is full gradient:
- gray radial glow + white vertical gradient.
+- A faint ambient grid may sit above the base gradient but behind all app content:
+ - use a fixed `body::before` layer with very low-contrast line color
+ - keep the static grid a touch more visible than before, but still quiet
+ - any animation should be tiny spark nodes that travel on the grid lines, not wide screen sweeps
+ - use several small sparks across the page with varied directions so the motion feels distributed rather than like one falling line
+ - avoid additional decorative shell/menu entrance animations so the sparks are the only ambient page motion
+ - keep animation slow and drifting, not pulsing or flashy
+ - keep the grid masked/faded so it is strongest near the top and softer lower in the page
+- Auth/login screens use the same shared `body` background and spark effect as the app shell; auth surfaces should stay transparent around the card so the global background remains visible.
- Do not use `html` theme-gradient scripting for Safari chrome behavior.
## 3) Tokens
@@ -66,12 +75,28 @@ This is the single source of truth for dashboard/profile visual structure and UI
- **`margin-bottom: 10` is the site-wide standard gap between any tab/control bar and the card(s) below — use this everywhere, no exceptions**
- tab bar row must always be `min-height: var(--btn-height)` so the gap to the card never shifts when action buttons appear/disappear
- tabs stay on the left in source order
+ - when a tab has a count, use the same inline badge pattern as task detail tabs:
+ - `margin-left: 5`
+ - `font-size: 12`
+ - `font-weight: 600`
+ - `background: var(--card-bg-2)`
+ - `border: 1px solid var(--border)`
+ - `border-radius: 10px`
+ - `padding: 3px 8px`
+ - active tab count uses `var(--accent)` text; inactive uses `var(--text-muted)`
- action buttons group sits on the right using: `margin-left: auto`, `display: flex`, `align-items: center`, `gap: 8`
- do not use hardcoded spacer blocks (`width` filler divs) to force alignment
- icon-only filter/action buttons share the same row and align vertically with add buttons
- filter button wrapper: `` — `display: flex` prevents block stretching that misaligns the button vertically
- filter button: `className="btn btn-outline"` + `style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}`, icon only (no label), funnel SVG `
` at `13×13`; dropdown uses `site-header-avatar-menu` + `site-header-avatar-item` classes
- when the control bar uses a multi-column grid (to align with split-card layouts below), add `align-items: center` to the grid and `min-height: var(--btn-height)` to every column so row height is stable across all tab states
+ - Tasks/Projects page tab rules:
+ - default landing tab is `New Requests`
+ - keep `All Tasks`, `In Progress`, `On Hold`, `In Review`, and `Completed`
+ - replace the old `To Do` tab with:
+ - `New Requests` for `not_started` rows on `R00`
+ - `Revisions` for `not_started` rows on `R01+`
+ - the project table tabs on the right also use the same count badge pattern
## 7) Dashboard Grids (Team)
- Stat row: `grid-template-columns: 1fr 1fr 1fr 1.5fr`, `gap: 24`, `margin-bottom: 0`
@@ -112,7 +137,7 @@ This is the single source of truth for dashboard/profile visual structure and UI
- Meta/date text: `11px`
- Progress track: `height: 4px`, `radius: 2px`
- Percentage width slot: `min-width: 28px`
-- Empty states in all cards (dashboard, profile, tasks, projects, file sharing, etc.):
+- Empty states in all cards (dashboard, profile, tasks, projects, etc.):
- any `No ...` message is centered in the card body (`display:flex`, `align-items:center`, `justify-content:center`)
- use shared class treatment (`.card-empty-center`) for consistency
- avoid top-offset-only placement for empty text
@@ -135,8 +160,6 @@ This is the single source of truth for dashboard/profile visual structure and UI
- row vertical spacing via cell padding: typically `5px`
- Hot Tasks column widths:
- check `10%`, task `40%`, requested by `35%`, due by `15%`
-- Client Highlight column widths:
- - icon `5%`, company `22%`, contact `23%`, projects `13%`, open `13%`, outstanding `12%`, paid `12%`
- Sorting rule:
- Every visible data column header must be sortable.
- Use clickable header controls (`SortTh`) with ascending/descending indicator.
@@ -173,57 +196,58 @@ This is the single source of truth for dashboard/profile visual structure and UI
- Activity feed includes:
- actions performed by the viewed user
- `Task approved` and `Task rejected` entries for tasks assigned to that viewed user (even if actioned by someone else)
-## 12.5) File Sharing
-- Main split: `grid-template-columns: minmax(0, 1fr) minmax(0, 3fr)` for an effective `25 / 75` layout, `gap: 24`
-- Left stacked cards (`Pinned`, `Navigation`): `gap: 24`
-- Canonical folder structure:
- - team and subcontractor profile folders live at `Team/
`
- - company folders live at `Clients/`
- - project folders live at `Clients//Projects/`
- - every project auto-creates `00 Project Files`
- - task folders live at `Clients//Projects//`
- - every task auto-creates `Request Info`, `Working Files`, and `Old Books`
- - request submission files are copied into `Request Info/R00`, `R01`, and higher revision folders
- - renaming a team user, subcontractor user, company, project, or task must rename the matching filesystem folder
- - folder repair/backfill is additive only: create missing folders, leave existing folders untouched
- - request-info backfill must skip files that already exist and must not overwrite added working files or user-managed files
- - deleting a task in the portal moves its task folder into `Archive/Clients//Projects//` instead of deleting it
- - deleting a project in the portal moves its project folder into `Archive/Clients//Projects/` instead of deleting it
- - orphan reconciliation may move folders into archive when they no longer map to portal records
- - orphan team folders move to `Archive/Team/` when no team or subcontractor profile matches that folder name
- - orphan project folders move to `Archive/Clients//Projects/` when no portal project matches that folder name under that company
- - orphan reconciliation does not remove company roots or overwrite archived content; it merge-moves into archive
-- Access/home rules:
- - team members have full file access
- - client users with exactly one tied company use that company folder as home: `/Clients/`
- - client users tied to multiple companies use `/Clients` as home and only see company folders they are tied to
- - `/Clients` acts as a virtual parent for multi-company client users and must not expose unrelated company folders
- - subcontractor users use virtual home `/`
- - subcontractor virtual home shows `Team` plus only the project folders they are assigned to
- - subcontractor users skip all other root folders and must not see unrelated client or internal roots
- - subcontractor `Team` maps to the real shared `Team` folder
- - each subcontractor project folder is a virtual root that maps to the real client project path `/Clients//Projects/`
-- Right card header:
- - title label is `File Browser`
- - drag/drop note sits directly under the title and uses subtext sizing: `12px`
- - current file path uses the same `11px` uppercase label treatment as the title row family
- - toolbar does not show `+ Folder` or `Upload` buttons
- - upload happens by dragging and dropping files or folders into the right card
- - delete action uses the same geometry as the profile edit button (`top: 18px`, `right: 21px`, `8px` radius, `22px` height, `12px` text, `0 12px` padding) but uses danger styling with red hover/fill
-- Right card table:
- - uses dashboard table styling (`table-layout: fixed`, transparent header row, `10px` uppercase headers, `13px/12px` body text)
- - card padding matches dashboard widget shell (`18px 21px`)
- - clickable text inside the table highlights in accent; rows do not use block hover fill
- - body cells are borderless like dashboard card tables
- - body cell padding matches dashboard tables exactly: `5px`
- - visible headers (`Name`, `Size`, `Modified`) use shared sortable header controls
- - file/folder icon footprint is compact so row height stays on the same cadence as dashboard tables
- - alternating rows use a very subtle theme-aware tint for scanability
-- Navigation tree:
- - `Home` aligns to the first arrow column
- - connector line runs through the arrow center column
- - pinned folders are user-specific and must not be shared across users on the same device/browser session
- - hover is text-only accent color, no row fill
+
+## 12.25) Shared Portrait Logic
+- Shared profile/avatar rendering must use one common resolver path instead of page-specific inline ` ` fallbacks.
+- Canonical shared component: `ProfileAvatar`
+- Portrait source resolution order:
+ - explicit `avatarUrl` prop when already resolved
+ - `profile.avatar_url`
+ - profile lookup by `profileId`
+ - profile lookup by normalized display `name` as a fallback when only sender/actor name exists in derived dashboard data
+ - initials fallback when no profile image is available
+- Shared portrait behavior:
+ - image uses `object-fit: cover`
+ - shape is always circular (`border-radius: 50%`)
+ - fallback initials use accent background with dark text
+ - layout/header/task/project/dashboard surfaces should reuse the shared portrait resolver instead of hand-rolling image + initials logic
+
+## 12.3) Team Dashboard Data Logic
+- Team dashboard is shared across roles, but card data is role-filtered by scoped tasks/projects/companies.
+- Focus refresh + realtime refresh should be wired through the shared live-refresh path, not duplicated per page.
+- Scoped company/project/task IDs should be resolved through the shared scope resolver for client/external views.
+- Tall dashboard list cards should use viewport-driven height with internal scroll instead of hard-coded row counts:
+ - measure the lower dashboard row against the current window height and size the cards to the remaining viewport space
+ - do not use a generic fixed tall-card height that can force whole-page scrolling on shorter windows
+ - do not apply a lower clamp that stops the card from continuing to compress with the window
+ - scrolling stays inside the card body, not on the full page layout
+- `Team Performance` card logic:
+ - source data is `deliveries` joined to `submissions` and `tasks`
+ - each delivery row must include `submission.task_id` plus nested task assignee info
+ - non-team roles must filter deliveries back down to their scoped task IDs before calculating performance rows
+ - month dropdown options are derived only from months that actually contain scoped delivery data
+ - initial render must use the first valid month with data; card must not render empty until the dropdown is manually changed
+ - performer identity resolves first from `task.assigned_to`, then falls back to normalized `sent_by` / display name matching against profiles
+ - avatar in `Team Performance` follows shared portrait resolution rules above
+ - counts:
+ - `version_number === 0` => `new`
+ - `version_number > 0` => `revision`
+ - progress bar percentage is performer completed count divided by total completed count in the active month
+- Dashboard lower-left task card logic:
+ - team view remains `Hot Tasks`
+ - client view remains `Tasks Ready For Review`
+ - external/subcontractor view is `My Tasks`
+ - subcontractor `My Tasks` must source directly from scoped tasks assigned to the current subcontractor, not from `is_hot` submission flags
+ - subcontractor `My Tasks` should include only active assigned tasks:
+ - `not_started`
+ - `in_progress`
+ - `client_review`
+ - `on_hold`
+ - subcontractor `My Tasks` should exclude completed/closed states such as:
+ - `client_approved`
+ - `invoiced`
+ - `paid`
+ - subcontractor `My Tasks` rows should show task, project, status, and due date
## 13) Radius + Geometry Rules
- Dashboard/profile widgets: `8px` radius
@@ -250,6 +274,22 @@ This is the single source of truth for dashboard/profile visual structure and UI
- light mode: outline labels use light-theme primary text; danger labels use danger text
- outline button border uses the same `var(--border)` token as card outlines unless a button role explicitly overrides it
- keep existing button role styling intact (`outline`, `primary`, `danger`); geometry changes, not role colors
+ - popup/modal footer action rows are a no-wrap horizontal row:
+ - `display: flex`
+ - `justify-content: flex-end`
+ - `gap: 8px`
+ - `flex-wrap: nowrap`
+ - popup/modal action buttons use a stable minimum width so loading labels do not shove adjacent buttons underneath:
+ - `min-width: 108px`
+ - center content horizontally
+ - request/add-task popups, review/revision/reject/amend popups, and other form submit dialogs should keep the save/submit/cancel buttons on one line while the submit label changes to `Saving...` / `Submitting...`
+ - status action buttons that change the task state must update the page immediately and lock while the save is in flight so users cannot double-press:
+ - flip local UI state optimistically
+ - disable related status buttons until request finishes
+ - if save fails, roll back local state and show error
+- route/page loading should continue using the shared `PageLoader` until page data is actually ready
+- avoid plain page-level `Loading...` text fallbacks after navigation, or the popup can disappear while the route is still fetching
+ - pages with longer initial fetches should pass their route loading state into shared layout/page shells so the popup stays up until all first-load data for that screen is ready
- Inputs/dropdowns may use their own control radius unless a page-specific rule says otherwise
- Circular elements (avatar/day/icon badges): `50%`
@@ -261,7 +301,7 @@ This is the single source of truth for dashboard/profile visual structure and UI
## 14.5) Loading Popup
- Use shared `PageLoader` component for loading overlays instead of page-specific popup implementations.
-- Loading popups/overlays, including shared site-wide page loaders and file-sharing loading states, use the same widget shell as dashboard cards:
+- Loading popups/overlays, including shared site-wide page loaders, use the same widget shell as dashboard cards:
- `background: var(--popup-bg)`
- `border: 1px solid var(--border)`
- `border-radius: 8px`
@@ -289,6 +329,13 @@ This is the single source of truth for dashboard/profile visual structure and UI
- textarea text: `13px`, color `var(--text-primary)`
- field surfaces and borders use global theme tokens (`var(--card-bg-2)` + `var(--border)`) for dark/light parity
- modal form action buttons use the standard outline geometry/style (`btn btn-outline`) unless a page explicitly defines a different role
+- Finance modal numeric fields (expense amount, invoice money inputs, line-item quantity/rate fields) follow the same shell system instead of custom oversized styles:
+ - amount/currency field outer shell: `min-height: 42px`, `border-radius: 6px`, `padding: 0 12px`, `background: var(--card-bg-2)`, `border: 1px solid var(--border)`
+ - currency symbol is separate from the numeric input and uses secondary text
+ - amount input text: `22px`, `500`, `line-height: 1.1`, right aligned, `font-variant-numeric: tabular-nums`
+ - line-item numeric inputs keep the normal field feel: `min-height: 32px`, `13px` text
+ - quantity is centered; rates/prices/totals are right aligned with tabular numerals
+ - do not use giant red standalone number inputs; red is reserved for negative/danger/read-only emphasis, not default editable money entry
## 14.7) Status Tags
- Shared status tags use `StatusBadge` (`.badge.badge-status`).
@@ -329,7 +376,7 @@ This is the single source of truth for dashboard/profile visual structure and UI
- fast `160ms`
- base `220ms`
- easing `cubic-bezier(0.22, 1, 0.36, 1)`
-- Dropdown animation: `ui-fade-up` from `translateY(4px)` + opacity 0 -> 1
+- Dropdown/menu entrance animation should be minimal; avoid decorative motion when it competes with the shared background spark effect.
## 17) Hover Interaction Contract
- Sidebar, header icon buttons, dropdown items, and avatar menu items must show a visible hover surface before click.
@@ -341,10 +388,6 @@ This is the single source of truth for dashboard/profile visual structure and UI
- Hover and active must be visually distinct:
- hover uses stronger temporary contrast (`bg` + thin border),
- active remains persistent selected-state background.
-- File Sharing left navigation exception:
- - `Pinned` + `Navigation` tree rows use text-only hover highlight (`color: var(--accent)`).
- - Do not apply row background fill for hover in this section.
-
## 17.5) Link Interaction Standard
- Use shared link classes only; do not hand-roll page-specific link hover styles:
- table/text links inside cards and tables: `table-link`
@@ -357,7 +400,7 @@ This is the single source of truth for dashboard/profile visual structure and UI
- Do not rely on inline style color overrides for hover behavior; if a variant is needed, add/extend a shared class in `index.css`.
## 16) Non-Negotiable Implementation Rules
-- Keep gradient backgrounds on `html`, not `body`
+- Keep gradient backgrounds on `body`, not `html`
- Keep widget shell values (`18px 21px`, `8px`, blur 12, border token) consistent
- Maintain global `24px` spacing rhythm for page/frame/grid gaps
- Keep team dashboard card order:
@@ -366,9 +409,351 @@ This is the single source of truth for dashboard/profile visual structure and UI
3. Net Profit
4. Revenue (wide)
- Keep row 2 order: Recent Activity (left), Tasks In Progress (center), Calendar (right fixed at `280px`)
+- Keep the lower dashboard row shared across all three roles:
+ 1. role-specific task list card
+ 2. Team Performance
+- Do not add an extra team-only `Client Highlight` card under the shared dashboard rows
+
+## 17) Team Reports
+- Team-only tools nav includes `Reports`.
+- Reports page is a finance-adjacent audit surface, but separate from the invoice page.
+- Primary report source is task-version billing units, not lump tasks:
+ - `R00` is its own row
+ - each real revision version (`R01`, `R02`, and so on) is its own row
+- Report rows must derive from the same version-unit billing logic as invoice creation:
+ - initial/new rows map to task-level client invoice units
+ - revision rows map to submission-level client invoice units
+ - hidden review-shadow submissions must not create report rows
+- Minimum columns:
+ - company
+ - project
+ - task name
+ - `R##`
+ - type (`New` / `Revision`)
+ - version workflow status
+ - billing status tag (`Not Invoiced`, `Invoiced`, `Paid`)
+- Reports page must support filtering by:
+ - company
+ - project
+- Reports page must support PDF export of the currently filtered rows.
## 18) Invoice Rules
- New work (`R00`) is billed at the new-service rate.
- Revision `R01` is free.
- Revisions `R02+` are billed incrementally: each uninvoiced revision entry contributes exactly `1x` revision charge.
- Previously invoiced revisions are excluded; later invoices only charge newly uninvoiced revisions (e.g. if `R02` was already invoiced, at `R04` only `R03` + `R04` are billed).
+- Billing is version-unit based, not task-lump based:
+ - `R00` is one unit
+ - `R01` is one unit
+ - `R02+` are separate units
+- Team -> client invoice availability:
+ - a task/version can appear only if it is client approved
+ - it must not have been invoiced to the client before
+ - already invoiced or paid client invoice units must not appear again
+ - revision availability must be sourced per version submission, not blocked just because the task's `R00` was already invoiced
+ - invoice add-item UI should show a visible type chip for each unit:
+ - `New` for `R00`
+ - `Revision` for `R01+`
+ - version availability is tied to completed version state, not only the task's current active state:
+ - if a newer version is now active (`not_started`, `in_progress`, etc.), older completed uninvoiced versions must still appear
+ - example: if `R02` is now active, uninvoiced `R01` may still appear while `R02` must stay off the invoice list until it is completed/approved
+ - team invoice popup and standalone team invoice page must both use the same shared version-eligibility source, not separate page-local rules
+ - clicking a team invoice from the `/finances` invoice list should open the same shared invoice-detail popup content used by the standalone team invoice detail route, not a separate simplified modal
+ - creating a new team invoice should also open that same shared invoice-detail popup after save
+- Subcontractor -> Fourge invoice availability:
+ - a task/version can appear only if that subcontractor actually worked/delivered that version
+ - it must be client approved
+ - it must not have been invoiced by that subcontractor to Fourge before
+ - already invoiced or paid subcontractor invoice units must not appear again
+- Client invoice history and subcontractor invoice history are separate streams:
+ - the same task/version may exist in client billing and subcontractor billing
+ - duplicate prevention happens within each stream separately
+
+## 18.0) Request And Task Lifecycle
+- A task starts as a request created by `team` or `client`.
+- The initial request is version `R00`.
+- `R00` request data lives on the initial `submission` row.
+- Visible `R##` labels on task lists/cards/detail must use the task's true current version:
+ - derive from `max(tasks.current_version, submission.version_number)`
+ - do not derive the visible `R##` from the deadline-bearing submission row
+ - deadline/request metadata may come from the deadline source submission, but the displayed revision number must still reflect the true current version
+- Client and team can amend the current request without creating a new visible revision item:
+ - amendment stays tied to the same active version
+ - amendment does not increment `R##`
+- Team or subcontractor works the task and places it in review.
+- Review submissions belong in the `Submissions` tab and may happen multiple times for the same `R##`.
+- Client decision on a review:
+ - `Approve`:
+ - task moves to `client_approved`
+ - task remains on the approved version
+ - `Reject`:
+ - task returns to `in_progress`
+ - same assigned worker stays on the task
+ - `R##` does not increment by rejection alone
+ - rejected reason is stored as `Rejected Notes` tied to that same version
+- A real revision request creates the next visible version:
+ - `R00` -> `R01`
+ - `R01` -> `R02`
+ - etc.
+ - when a revision request creates the next `R##`, the task keeps the same last assignee instead of clearing assignment
+ - status resets to `not_started`, but the assignee remains attached so the work comes back to the same person by default
+- Revision requests are separate from rejection notes:
+ - revision request = new visible request/version item
+ - rejection note = side note on the current version
+- Re-review after rejection must still appear in `Submissions` without creating a fake new visible request/revision row.
+
+## 18.1) Task File Placement Rules
+- Company and project folder chain:
+ - when a new company is created by team, the portal ensures a matching server folder exists:
+ - `/Clients/{Company}`
+ - when a company name is changed, the company folder is renamed to match
+ - when a new subcontractor is created by team, the portal ensures a matching server folder exists:
+ - `{FILEBROWSER_SUBS_ROOT}/{Subcontractor Name}`
+ - when a subcontractor name is changed, that subcontractor folder is renamed to match
+ - when a new project is created, the portal ensures a matching server folder exists:
+ - `/Clients/{Company}/Projects/{Project}`
+ - inside each new project folder, the portal also pre-creates:
+ - `00 Project Files`
+ - when a project name is changed, the project folder is renamed to match
+ - when a new task request is created, the portal ensures a matching server folder exists:
+ - `/Clients/{Company}/Projects/{Project}/{Task}`
+ - inside each new task folder, the portal also pre-creates:
+ - `Old Books`
+ - `Working Files`
+ - `Survey`
+ - folder creation now works via FileBrowser API using:
+ - a valid production `FILEBROWSER_TOKEN`
+ - FileBrowser source `srv`
+ - folder sync uses FileBrowser only for server folder creation
+ - this does not restore the old file-sharing page or task folder tab
+- Initial task request files:
+ - created by `team` or `client`
+ - attach to the initial request in the `Overview` tab
+ - stored on the initial `submission`
+ - file records live in `submission_files`
+ - request file flow now works in this order:
+ - ensure the matching task folder exists under `/Clients/{Company}/Projects/{Project}/{Task}`
+ - upload each attached file into Supabase bucket `submissions`
+ - store the file row in `submission_files`
+ - then mirror that same saved file from Supabase into the server-side `Survey` folder
+ - initial request files are stored in Supabase under the task's `Survey` path for portal access
+ - storage path format:
+ - `{taskId}/Survey/{timestamp}_{originalFileName}`
+ - initial request files are also mirrored into the server-side task folder:
+ - `/Clients/{Company}/Projects/{Project}/{Task}/Survey`
+ - the mirror now copies from the saved Supabase storage object on the server side instead of re-uploading the browser file body
+ - multiple attached request files are processed one by one through that same mirror flow and should all land in the same `Survey` folder
+ - the live working behavior is:
+ - request attachments still show inside the portal from Supabase
+ - the same attachments also appear in the matching server `Survey` folder
+ - for `Brand Book` requests, sign details are stored directly on the `submission` row
+ - sign detail payload lives in `submissions.signs`
+ - old `submission_signs` rows are migrated into `submissions.signs` and no longer used by the portal
+ - sign info in the `Overview` tab renders as plain text rows with no inset background card
+- Revision request files:
+ - created by `team` or `client`
+ - attach to the specific revision entry in the `Revisions` tab
+ - stored on the revision `submission`
+ - file records live in `submission_files`
+- Review submission files:
+ - created by `team` or `subcontractor`
+ - attach to the specific submitted review entry in the `Submissions` tab
+ - stored on the `delivery`
+ - file records live in `delivery_files`
+- Client rejection notes:
+ - when client rejects a task in review, it must not create a new request/revision item
+ - rejection opens a note box
+ - rejection returns the task to `in_progress`
+ - rejection keeps the same assigned worker on the task
+ - rejection does not increment the `R##` version by itself
+ - note is tied to the exact version being rejected
+ - `R00` rejection note shows beside the `R00` request info
+ - `R01` rejection note shows beside the `R01` request info
+ - `R02` rejection note shows beside the `R02` request info
+ - label should read `Rejected Notes`
+ - these notes are separate from normal comments and should not inflate the visible comments count
+
+## 18.2) Task Notification Rules
+- New request:
+ - when a new request is created, it emails:
+ - all team members
+ - this applies from client request flows and team request flows
+- In review:
+ - when a task is placed in review, it emails:
+ - the client
+ - all team members
+ - the assigned user
+- On hold:
+ - when a task is placed on hold, it emails:
+ - the client
+ - all team members
+ - the assigned user
+- Amend request:
+ - when a request is amended, it emails:
+ - the client
+ - all team members
+ - the assigned user
+- Rejected:
+ - when a client rejects a task, it emails:
+ - the client
+ - all team members
+ - the assigned user
+ - rejected task email uses the shared task-status layout only
+ - do not also send a separate legacy revision email
+- Approved:
+ - when a client approves a task, it emails:
+ - the client
+ - all team members
+ - the assigned user
+- Revision request:
+ - when a revision request is created, it emails:
+ - the client
+ - all team members
+ - the assigned user
+ - revision request should send one shared task-status email only
+ - do not also send any older legacy revision email
+
+## 19) Finance Flow
+- Finance is role-split, not one shared page across all users:
+ - team finance hub: `/finances`
+ - client invoice view: `/client-invoices`
+ - subcontractor invoice view: `/subs-invoices`
+ - subcontractor PO view: `/my-purchase-orders`
+- Finance is shared in concept, but not one universal page component yet:
+ - team sees the full finance hub
+ - client sees receive/view/pay invoice flow only
+ - subcontractor sees invoice-Fourge and PO-review flow only
+
+### 19.1) Team -> Client Invoice Flow
+- Team creates invoice for the client.
+- Team invoice availability is version-unit based:
+ - `R00` can bill as new work
+ - `R01` is tracked but free
+ - `R02+` can bill as revision units
+- A client invoice unit may appear only if:
+ - it is client approved
+ - it has not already been invoiced to the client
+- Team sends invoice to the client.
+- Invoice send email goes to:
+ - the client recipient
+ - all team members
+- Client does not create invoices back to Fourge in the portal.
+- Client only receives, views, pays, and downloads invoice records.
+- Client payment can happen:
+ - through Stripe / portal pay flow
+ - outside the portal, then team marks the invoice paid manually
+- Once paid:
+ - invoice status is `paid`
+ - branded paid/receipt email goes to:
+ - the client recipient
+ - all team members
+ - receipt can be generated/downloaded/sent if needed
+ - paid client invoices must not be deletable by client or team
+- Team invoice detail remains the control point for:
+ - send / resend invoice
+ - mark paid
+ - generate invoice PDF
+ - generate/send receipt
+
+### 19.2) Client Finance Role
+- Clients only see invoices relevant to their company access.
+- Clients can:
+ - view invoices
+ - filter invoices by company when tied to multiple companies
+ - download invoice PDFs
+- Clients cannot:
+ - create invoices to Fourge
+ - manage expenses
+ - manage subcontractor invoices
+ - manage POs
+
+### 19.3) Subcontractor -> Fourge Invoice Flow
+- Subcontractor creates invoice to bill Fourge for completed work.
+- Subcontractor billing is version-based, not task-lump based:
+ - `R00` is one billable unit
+ - `R01` is one version unit but free
+ - `R02+` are version units and billable
+- Subcontractor invoice create page builds available rows from actual delivered versions tied to that subcontractor's assigned tasks.
+- Only versions the subcontractor actually delivered should appear as available invoice rows.
+- Subcontractor invoice availability must dedupe per `task + version`.
+- If a subcontractor invoice already includes a specific version, that same version must not show up again in later subcontractor invoices.
+- A subcontractor task/version is available only when:
+ - it is client approved
+ - that subcontractor delivered/worked that version
+ - it has not already been invoiced to Fourge by that subcontractor
+- Version availability follows the same older-completed-version rule as team client invoices:
+ - if a newer version is now active (`not_started`, `in_progress`, etc.), older completed uninvoiced versions must still appear
+ - example: if `R02` is now active, uninvoiced `R01` may still appear while `R02` must stay off the subcontractor invoice list until it is completed/approved
+ - subcontractor invoice create page must use that same shared version-eligibility source as the team invoice flows
+- That invoice appears to team finance for review/payment.
+- Team marks subcontractor invoices `paid`.
+- Subcontractors do not normally mark their own invoices paid.
+- Once paid:
+ - subcontractor sees paid status
+ - branded paid/receipt email goes to:
+ - that subcontractor
+ - all team members
+ - receipt can be generated/downloaded/sent if needed
+- Client invoicing and subcontractor invoicing are separate streams:
+ - team can invoice the client for a task/version
+ - subcontractor can invoice Fourge for that same task/version
+- Client invoice state must not block subcontractor invoice availability by itself.
+- Subcontractor invoice state only blocks the same subcontractor task/version from being billed again.
+- Subcontractor finance page `/subs-invoices` stays separate from the team finance hub:
+ - no chart on this page
+ - top row uses four stat cards only: completed new tasks, completed revisions, invoiced, paid
+ - invoice history sits below in one full-width card with no secondary right-side companion card
+ - the `+ Invoice` action uses the same shared popup shell/button treatment as the team finance invoice modal
+ - subcontractor invoice modal keeps subcontractor-specific fields and billing rules, but visually follows the shared finance popup pattern
+ - clicking a subcontractor invoice from the list should open the shared invoice detail in a popup, matching the team-side subcontractor invoice interaction
+ - after a subcontractor creates an invoice, the portal should open that same popup detail view instead of routing away to a separate detail page
+ - subcontractor invoice popup layout should match the team-side subcontractor invoice popup layout
+ - subcontractor invoice popup action row should include:
+ - `Download Invoice`
+ - `Submit to Team` when still draft
+ - `Download Receipt` when paid
+ - subcontractor invoice popup line-item table should include:
+ - `Work` (`New` or `Revision`)
+ - `R#` (`R00`, `R01`, and so on)
+ - `Description`
+ - `Type` (task service type such as `Brand Book`, `Sign Family`, and so on)
+ - `Qty`
+ - `Unit Price`
+ - `Amount`
+ - subcontractor invoice detail uses one shared layout across team and subcontractor roles, with role-specific actions loaded into the same detail shell
+ - shared subcontractor invoice detail pattern:
+ - uses the shared site header chevron/back pattern from task detail, not a separate page-local header block
+ - content starts immediately under that shared header using the same task-detail rhythm, with no separate action bar floating above the first card row
+ - header title styling follows the global site header spec:
+ - `28px`
+ - `500`
+ - `var(--text-primary)`
+ - `line-height: 1.2`
+ - header subtitle styling follows the global site header spec:
+ - `13px`
+ - `var(--text-muted)`
+ - two top summary cards are the first content row, using the same `24px` gap rhythm as task detail
+ - role-specific invoice actions sit inside the top-right summary card header instead of on a separate row
+ - summary and section labels use widget-title styling:
+ - `11px`
+ - `500`
+ - uppercase
+ - `letter-spacing: 0.8px`
+ - `var(--text-secondary)`
+ - line items live in one full-width card with sticky table header and total footer
+ - notes card sits below line items when notes exist
+
+### 19.4) Team -> Subcontractor PO Flow
+- Team creates purchase order for subcontractor work.
+- PO can optionally be tied to a project and task-backed line items.
+- PO email sending is currently disabled.
+- Subcontractor can approve the PO from their portal view.
+- PO approval is separate from subcontractor invoice payment.
+- A PO being approved does not itself mean an invoice has been paid.
+
+### 19.5) Simple Mental Model
+- Fourge -> Client = invoice out
+- Client -> Fourge = payment in
+- Subcontractor -> Fourge = invoice in
+- Fourge -> Subcontractor = payment out
+- Fourge -> Subcontractor before payment = PO / work authorization
diff --git a/scripts/backfill-project-files-folder.mjs b/scripts/backfill-project-files-folder.mjs
deleted file mode 100644
index 0e8afa9..0000000
--- a/scripts/backfill-project-files-folder.mjs
+++ /dev/null
@@ -1,83 +0,0 @@
-// One-time: creates 00 Project Files folder inside every existing project folder in FileBrowser
-// Run: node scripts/backfill-project-files-folder.mjs
-import { readFileSync } from 'fs';
-import { resolve, dirname } from 'path';
-import { fileURLToPath } from 'url';
-import { createClient } from '@supabase/supabase-js';
-
-const __dir = dirname(fileURLToPath(import.meta.url));
-const envFile = resolve(__dir, '../.env.backfill');
-
-const env = {};
-readFileSync(envFile, 'utf8').split('\n').forEach(line => {
- const m = line.match(/^([^#=]+)=(.*)$/);
- if (m) env[m[1].trim()] = m[2].trim().replace(/^["']|["']$/g, '');
-});
-
-const SUPABASE_URL = env.VITE_SUPABASE_URL || env.SUPABASE_URL;
-const SERVICE_ROLE_KEY = env.SUPABASE_SERVICE_ROLE_KEY;
-const FB_URL = (env.FILEBROWSER_URL || '').replace(/\/+$/, '');
-const FB_TOKEN = env.FILEBROWSER_TOKEN;
-const CLIENT_ROOT = env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients';
-const FB_SOURCE = 'files';
-
-if (!SUPABASE_URL || !SERVICE_ROLE_KEY) { console.error('Missing Supabase env'); process.exit(1); }
-if (!FB_URL || !FB_TOKEN) { console.error('Missing FileBrowser env'); process.exit(1); }
-
-const admin = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, { auth: { persistSession: false } });
-
-function safeName(v) {
- return String(v || '').trim()
- .replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
- .replace(/\s+/g, ' ')
- .replace(/^-+|-+$/g, '');
-}
-
-function joinPath(...parts) {
- const raw = parts.join('/');
- const clean = raw.split('/').filter(p => p && p !== '.');
- return `/${clean.join('/')}`;
-}
-
-async function mkdir(path) {
- const qs = new URLSearchParams({ source: FB_SOURCE, path, isDir: 'true' }).toString();
- const res = await fetch(`${FB_URL}/api/resources?${qs}`, {
- method: 'POST',
- headers: { Authorization: `Bearer ${FB_TOKEN}` },
- });
- if (!res.ok) {
- const text = await res.text();
- if (!text.includes('already') && !text.includes('exist')) {
- console.warn(` mkdir ${path}: ${res.status} ${text.slice(0, 80)}`);
- }
- }
-}
-
-async function main() {
- const { data: projects, error } = await admin
- .from('projects')
- .select('id, name, company:companies(name)');
-
- if (error) { console.error('Query failed:', error.message); process.exit(1); }
-
- console.log(`Found ${projects.length} projects`);
-
- for (const p of projects) {
- const company = safeName(p.company?.name || '');
- const project = safeName(p.name || '');
- if (!company || !project) { console.log(` Skipping (missing name): ${p.id}`); continue; }
-
- const projectDir = joinPath(CLIENT_ROOT, company, 'Projects', project);
- const targetDir = joinPath(projectDir, '00 Project Files');
-
- await mkdir(joinPath(CLIENT_ROOT, company));
- await mkdir(joinPath(CLIENT_ROOT, company, 'Projects'));
- await mkdir(projectDir);
- await mkdir(targetDir);
- console.log(` ✓ ${company} / ${project} / 00 Project Files`);
- }
-
- console.log('\nDone.');
-}
-
-main().catch(err => { console.error(err); process.exit(1); });
diff --git a/scripts/backfill-project-folders.mjs b/scripts/backfill-project-folders.mjs
deleted file mode 100644
index daac131..0000000
--- a/scripts/backfill-project-folders.mjs
+++ /dev/null
@@ -1,127 +0,0 @@
-#!/usr/bin/env node
-// Backfill FileBrowser folders for all existing projects and tasks.
-// Project: Clients/{company}/Projects/{project}/00 Project Files/ + 00 Project Info/
-// Task: Clients/{company}/Projects/{project}/{task}/Working Files/ + Request Info/
-// Run: node --env-file=.env.backfill scripts/backfill-project-folders.mjs
-
-const FB_SOURCE = 'files';
-const FILEBROWSER_URL = (process.env.FILEBROWSER_URL || 'https://fourgebranding.krao.us').replace(/\/+$/, '');
-const FILEBROWSER_TOKEN = process.env.FILEBROWSER_TOKEN;
-const CLIENT_ROOT = process.env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients';
-const SUPABASE_URL = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL;
-const SUPABASE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
-
-if (!FILEBROWSER_TOKEN) { console.error('Missing FILEBROWSER_TOKEN'); process.exit(1); }
-if (!SUPABASE_URL || !SUPABASE_KEY) { console.error('Missing Supabase env'); process.exit(1); }
-
-function safeName(value) {
- return String(value || '')
- .trim()
- .replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
- .replace(/\s+/g, ' ')
- .replace(/^-+|-+$/g, '');
-}
-
-function normalizePath(path) {
- const parts = String(path || '/').split('/').filter(p => p && p !== '.' && p !== '..');
- return `/${parts.join('/')}`;
-}
-
-function joinPath(...parts) {
- return normalizePath(parts.join('/'));
-}
-
-async function mkdir(path) {
- const qs = new URLSearchParams({ source: FB_SOURCE, path, isDir: 'true' }).toString();
- const res = await fetch(`${FILEBROWSER_URL}/api/resources?${qs}`, {
- method: 'POST',
- headers: { Authorization: `Bearer ${FILEBROWSER_TOKEN}` },
- });
- // 200 = created, 409 = already exists — both fine
- if (!res.ok && res.status !== 409) {
- const text = await res.text();
- throw new Error(`mkdir ${path} failed (${res.status}): ${text}`);
- }
- return res.status;
-}
-
-async function supabaseFetch(path) {
- const res = await fetch(`${SUPABASE_URL}/rest/v1/${path}`, {
- headers: {
- apikey: SUPABASE_KEY,
- Authorization: `Bearer ${SUPABASE_KEY}`,
- },
- });
- if (!res.ok) throw new Error(`Supabase ${path}: ${await res.text()}`);
- return res.json();
-}
-
-async function run() {
- console.log('Fetching projects from Supabase...');
- const projects = await supabaseFetch('projects?select=id,name,company:companies(name)&order=created_at.asc');
- console.log(`Found ${projects.length} projects.`);
-
- console.log('Fetching tasks from Supabase...');
- const tasks = await supabaseFetch('tasks?select=id,title,project:projects(name,company:companies(name))&order=submitted_at.asc');
- console.log(`Found ${tasks.length} tasks.\n`);
-
- let created = 0;
- let existing = 0;
- let errors = 0;
-
- // ── Projects ──────────────────────────────────────────────────────────────
- console.log('=== PROJECTS ===');
- for (const project of projects) {
- const companyName = project.company?.name;
- if (!companyName) { console.log(` SKIP ${project.name} — no company`); continue; }
-
- const companyDir = joinPath(CLIENT_ROOT, safeName(companyName));
- const projectsDir = joinPath(companyDir, 'Projects');
- const projectDir = joinPath(projectsDir, safeName(project.name));
-
- try {
- await mkdir(companyDir);
- await mkdir(projectsDir);
- const s = await mkdir(projectDir);
- await mkdir(joinPath(projectDir, '00 Project Files'));
- await mkdir(joinPath(projectDir, '00 Project Info'));
-
- if (s === 409) { console.log(` EXISTS ${companyName} / ${project.name}`); existing++; }
- else { console.log(` CREATED ${companyName} / ${project.name}`); created++; }
- } catch (err) {
- console.error(` ERROR ${companyName} / ${project.name}: ${err.message}`);
- errors++;
- }
- }
-
- // ── Tasks ─────────────────────────────────────────────────────────────────
- console.log('\n=== TASKS ===');
- for (const task of tasks) {
- const projectName = task.project?.name;
- const companyName = task.project?.company?.name;
- if (!projectName || !companyName) { console.log(` SKIP ${task.title} — missing project/company`); continue; }
-
- const projectDir = joinPath(CLIENT_ROOT, safeName(companyName), 'Projects', safeName(projectName));
- const taskDir = joinPath(projectDir, safeName(task.title));
-
- try {
- // Ensure parent exists (idempotent)
- await mkdir(joinPath(CLIENT_ROOT, safeName(companyName)));
- await mkdir(joinPath(CLIENT_ROOT, safeName(companyName), 'Projects'));
- await mkdir(projectDir);
- const s = await mkdir(taskDir);
- await mkdir(joinPath(taskDir, 'Working Files'));
- await mkdir(joinPath(taskDir, 'Request Info'));
-
- if (s === 409) { console.log(` EXISTS ${companyName} / ${projectName} / ${task.title}`); existing++; }
- else { console.log(` CREATED ${companyName} / ${projectName} / ${task.title}`); created++; }
- } catch (err) {
- console.error(` ERROR ${companyName} / ${projectName} / ${task.title}: ${err.message}`);
- errors++;
- }
- }
-
- console.log(`\nDone. Created: ${created} Already existed: ${existing} Errors: ${errors}`);
-}
-
-run().catch(err => { console.error(err); process.exit(1); });
diff --git a/scripts/backfill-request-files.mjs b/scripts/backfill-request-files.mjs
deleted file mode 100644
index c56851b..0000000
--- a/scripts/backfill-request-files.mjs
+++ /dev/null
@@ -1,212 +0,0 @@
-// One-time script: copies existing submission files from Supabase Storage to FileBrowser
-// Run: node scripts/backfill-request-files.mjs
-import { readFileSync } from 'fs';
-import { createClient } from '@supabase/supabase-js';
-import { resolve, dirname } from 'path';
-import { fileURLToPath } from 'url';
-
-const __dir = dirname(fileURLToPath(import.meta.url));
-const envFile = resolve(__dir, '../.env.backfill');
-
-// Parse env file
-const env = {};
-readFileSync(envFile, 'utf8').split('\n').forEach(line => {
- const m = line.match(/^([^#=]+)=(.*)$/);
- if (m) env[m[1].trim()] = m[2].trim().replace(/^["']|["']$/g, '');
-});
-
-const SUPABASE_URL = env.VITE_SUPABASE_URL || env.SUPABASE_URL;
-const SERVICE_ROLE_KEY = env.SUPABASE_SERVICE_ROLE_KEY;
-const FB_URL = (env.FILEBROWSER_URL || '').replace(/\/+$/, '');
-const FB_TOKEN = env.FILEBROWSER_TOKEN;
-const CLIENT_ROOT = env.FILEBROWSER_CLIENT_ROOT || '/Clients';
-const FB_SOURCE = 'srv';
-
-if (!SUPABASE_URL || !SERVICE_ROLE_KEY) { console.error('Missing Supabase env'); process.exit(1); }
-if (!FB_URL || !FB_TOKEN) { console.error('Missing FileBrowser env'); process.exit(1); }
-
-const admin = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, {
- auth: { persistSession: false, autoRefreshToken: false },
-});
-
-function normalizePath(path) {
- const parts = String(path || '/').trim().split('/').filter(Boolean);
- const clean = [];
- for (const p of parts) {
- if (p === '.') continue;
- if (p === '..') throw new Error('path traversal');
- clean.push(p);
- }
- return `/${clean.join('/')}`;
-}
-
-function joinPath(...parts) { return normalizePath(parts.join('/')); }
-
-function safeName(v) {
- return String(v || '').trim()
- .replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
- .replace(/\s+/g, ' ')
- .replace(/^-+|-+$/g, '');
-}
-
-async function fbFetch(method, endpoint, { params = {}, headers = {}, body } = {}) {
- const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString();
- const res = await fetch(`${FB_URL}${endpoint}?${qs}`, {
- method,
- headers: { Authorization: `Bearer ${FB_TOKEN}`, ...headers },
- body,
- });
- if (!res.ok) {
- const text = await res.text();
- throw new Error(`FB ${res.status}: ${text.slice(0, 200)}`);
- }
- return res;
-}
-
-async function mkdir(path) {
- await fbFetch('POST', '/api/resources', { params: { path, isDir: 'true' } }).catch(() => {});
-}
-
-async function fbExists(path) {
- try {
- await fbFetch('GET', '/api/resources', { params: { path } });
- return true;
- } catch (e) {
- if (String(e.message || '').includes('404')) return false;
- throw e;
- }
-}
-
-async function ensureTaskStructure(companyName, projectName, taskTitle) {
- const companyDir = joinPath(CLIENT_ROOT, safeName(companyName));
- const projectsDir = joinPath(companyDir, 'Projects');
- const projectDir = joinPath(projectsDir, safeName(projectName));
- const taskDir = joinPath(projectDir, safeName(taskTitle));
- const requestInfoDir = joinPath(taskDir, 'Request Info');
- const workingFilesDir = joinPath(taskDir, 'Working Files');
- const oldBooksDir = joinPath(taskDir, 'Old Books');
-
- await mkdir(companyDir);
- await mkdir(projectsDir);
- await mkdir(projectDir);
- await mkdir(joinPath(projectDir, '00 Project Files'));
- await mkdir(taskDir);
- await mkdir(requestInfoDir);
- await mkdir(workingFilesDir);
- await mkdir(oldBooksDir);
-
- return { requestInfoDir };
-}
-
-async function main() {
- const { data: tasks, error: taskError } = await admin
- .from('tasks')
- .select(`
- id, title,
- project:projects!inner(
- id, name,
- company:companies!inner(name)
- )
- `);
-
- if (taskError) { console.error('Task query failed:', taskError.message); process.exit(1); }
-
- let ensuredTasks = 0;
- for (const task of tasks || []) {
- const project = task?.project;
- const company = project?.company;
- if (!task?.title || !project?.name || !company?.name) continue;
- await ensureTaskStructure(company.name, project.name, task.title);
- ensuredTasks++;
- }
-
- const { data: rows, error } = await admin
- .from('submission_files')
- .select(`
- id, name, storage_path,
- submission:submissions!inner(
- id, version_number,
- task:tasks!inner(
- id, title,
- project:projects!inner(
- id, name,
- company:companies!inner(name)
- )
- )
- )
- `);
-
- if (error) { console.error('Query failed:', error.message); process.exit(1); }
-
- // Group by task + version
- const groups = new Map();
- for (const row of rows || []) {
- const sub = row.submission;
- const task = sub?.task;
- const project = task?.project;
- const company = project?.company;
- if (!task || !project || !company) continue;
-
- const key = `${task.id}::${sub.version_number}`;
- if (!groups.has(key)) {
- groups.set(key, {
- companyName: company.name,
- projectName: project.name,
- taskTitle: task.title,
- versionNumber: sub.version_number,
- files: [],
- });
- }
- groups.get(key).files.push({ name: row.name, storage_path: row.storage_path });
- }
-
- console.log(`Ensured structure for ${ensuredTasks} tasks`);
- console.log(`Found ${groups.size} task/revision groups, ${rows.length} total files`);
-
- let processed = 0, skipped = 0, errors = 0;
-
- for (const [key, group] of groups) {
- if (group.files.length === 0) { skipped++; continue; }
-
- const revFolder = `R${String(group.versionNumber).padStart(2, '0')}`;
- const { requestInfoDir } = await ensureTaskStructure(group.companyName, group.projectName, group.taskTitle);
- const revDir = joinPath(requestInfoDir, revFolder);
-
- console.log(`\n[${group.companyName}] ${group.projectName} / ${group.taskTitle} / ${revFolder} (${group.files.length} files)`);
-
- await mkdir(revDir);
-
- for (const file of group.files) {
- try {
- const { data: blob, error: downloadErr } = await admin.storage
- .from('submissions')
- .download(file.storage_path);
- if (downloadErr || !blob) throw new Error(`download failed: ${downloadErr?.message}`);
- const fileBuffer = await blob.arrayBuffer();
- const form = new FormData();
- form.append('file', new Blob([fileBuffer]), file.name);
-
- const fbFilePath = joinPath(revDir, file.name);
- if (await fbExists(fbFilePath)) {
- console.log(` - skip existing ${file.name}`);
- skipped++;
- continue;
- }
- await fbFetch('POST', '/api/resources', {
- params: { path: fbFilePath },
- body: form,
- });
-
- console.log(` ✓ ${file.name}`);
- processed++;
- } catch (err) {
- console.error(` ✗ ${file.name}: ${err.message}`);
- errors++;
- }
- }
- }
-
- console.log(`\nDone. Processed: ${processed}, Skipped: ${skipped}, Errors: ${errors}`);
-}
-
-main().catch(err => { console.error(err); process.exit(1); });
diff --git a/scripts/cleanup-old-project-files-folders.mjs b/scripts/cleanup-old-project-files-folders.mjs
deleted file mode 100644
index 67053a4..0000000
--- a/scripts/cleanup-old-project-files-folders.mjs
+++ /dev/null
@@ -1,103 +0,0 @@
-// One-time: removes leftover '.Project Files' and 'Project Files' folders
-// that may still exist alongside the renamed '00 Project Files'
-// Run: node scripts/cleanup-old-project-files-folders.mjs
-import { readFileSync } from 'fs';
-import { resolve, dirname } from 'path';
-import { fileURLToPath } from 'url';
-import { createClient } from '@supabase/supabase-js';
-
-const __dir = dirname(fileURLToPath(import.meta.url));
-const envFile = resolve(__dir, '../.env.backfill');
-
-const env = {};
-readFileSync(envFile, 'utf8').split('\n').forEach(line => {
- const m = line.match(/^([^#=]+)=(.*)$/);
- if (m) env[m[1].trim()] = m[2].trim().replace(/^["']|["']$/g, '');
-});
-
-const SUPABASE_URL = env.VITE_SUPABASE_URL || env.SUPABASE_URL;
-const SERVICE_ROLE_KEY = env.SUPABASE_SERVICE_ROLE_KEY;
-const FB_URL = (env.FILEBROWSER_URL || '').replace(/\/+$/, '');
-const FB_TOKEN = env.FILEBROWSER_TOKEN;
-const CLIENT_ROOT = env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients';
-const FB_SOURCE = 'files';
-
-if (!SUPABASE_URL || !SERVICE_ROLE_KEY) { console.error('Missing Supabase env'); process.exit(1); }
-if (!FB_URL || !FB_TOKEN) { console.error('Missing FileBrowser env'); process.exit(1); }
-
-const admin = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, { auth: { persistSession: false } });
-
-function safeName(v) {
- return String(v || '').trim()
- .replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
- .replace(/\s+/g, ' ')
- .replace(/^-+|-+$/g, '');
-}
-
-function joinPath(...parts) {
- const clean = parts.join('/').split('/').filter(p => p && p !== '.');
- return `/${clean.join('/')}`;
-}
-
-async function fbList(path) {
- const qs = new URLSearchParams({ source: FB_SOURCE, path }).toString();
- const res = await fetch(`${FB_URL}/api/resources?${qs}`, {
- headers: { Authorization: `Bearer ${FB_TOKEN}` },
- });
- if (!res.ok) return null;
- const data = await res.json().catch(() => null);
- return data?.folders || [];
-}
-
-async function fbDelete(path) {
- const qs = new URLSearchParams({ source: FB_SOURCE, path }).toString();
- const res = await fetch(`${FB_URL}/api/resources?${qs}`, {
- method: 'DELETE',
- headers: { Authorization: `Bearer ${FB_TOKEN}` },
- });
- if (!res.ok) {
- const text = await res.text();
- throw new Error(`${res.status}: ${text.slice(0, 100)}`);
- }
-}
-
-const OLD_NAMES = ['.Project Files', 'Project Files'];
-
-async function main() {
- const { data: projects, error } = await admin
- .from('projects')
- .select('id, name, company:companies(name)');
-
- if (error) { console.error('Query failed:', error.message); process.exit(1); }
- console.log(`Checking ${projects.length} projects...\n`);
-
- let cleaned = 0;
-
- for (const p of projects) {
- const company = safeName(p.company?.name || '');
- const project = safeName(p.name || '');
- if (!company || !project) continue;
-
- const projectDir = joinPath(CLIENT_ROOT, company, 'Projects', project);
- const entries = await fbList(projectDir);
- if (!entries) { console.log(` ? could not list: ${company} / ${project}`); continue; }
-
- const names = entries.map(e => e.name);
- for (const oldName of OLD_NAMES) {
- if (names.includes(oldName)) {
- const oldPath = joinPath(projectDir, oldName);
- try {
- await fbDelete(oldPath);
- console.log(` ✓ deleted "${oldName}": ${company} / ${project}`);
- cleaned++;
- } catch (err) {
- console.error(` ✗ failed to delete "${oldName}" in ${company} / ${project}: ${err.message}`);
- }
- }
- }
- }
-
- console.log(`\nDone. Cleaned ${cleaned} old folder(s).`);
-}
-
-main().catch(err => { console.error(err); process.exit(1); });
diff --git a/scripts/generate-task-pdf.html b/scripts/generate-task-pdf.html
new file mode 100644
index 0000000..bffee65
--- /dev/null
+++ b/scripts/generate-task-pdf.html
@@ -0,0 +1,180 @@
+
+
+
+
+ Task Tracker PDF Generator
+
+
+
+
+
+
+ Task Tracker PDF
+ All Companies
+ Generate & Download PDF
+ Ready
+
+
+
+
diff --git a/scripts/rename-project-files-folder.mjs b/scripts/rename-project-files-folder.mjs
deleted file mode 100644
index 655cad8..0000000
--- a/scripts/rename-project-files-folder.mjs
+++ /dev/null
@@ -1,104 +0,0 @@
-// One-time: renames .00 Project Files → 00 Project Files for all existing project folders
-// Run: node scripts/rename-project-files-folder.mjs
-import { readFileSync } from 'fs';
-import { resolve, dirname } from 'path';
-import { fileURLToPath } from 'url';
-import { createClient } from '@supabase/supabase-js';
-
-const __dir = dirname(fileURLToPath(import.meta.url));
-const envFile = resolve(__dir, '../.env.backfill');
-
-const env = {};
-readFileSync(envFile, 'utf8').split('\n').forEach(line => {
- const m = line.match(/^([^#=]+)=(.*)$/);
- if (m) env[m[1].trim()] = m[2].trim().replace(/^["']|["']$/g, '');
-});
-
-const SUPABASE_URL = env.VITE_SUPABASE_URL || env.SUPABASE_URL;
-const SERVICE_ROLE_KEY = env.SUPABASE_SERVICE_ROLE_KEY;
-const FB_URL = (env.FILEBROWSER_URL || '').replace(/\/+$/, '');
-const FB_TOKEN = env.FILEBROWSER_TOKEN;
-const CLIENT_ROOT = env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients';
-const FB_SOURCE = 'files';
-
-if (!SUPABASE_URL || !SERVICE_ROLE_KEY) { console.error('Missing Supabase env'); process.exit(1); }
-if (!FB_URL || !FB_TOKEN) { console.error('Missing FileBrowser env'); process.exit(1); }
-
-const admin = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, { auth: { persistSession: false } });
-
-function safeName(v) {
- return String(v || '').trim()
- .replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
- .replace(/\s+/g, ' ')
- .replace(/^-+|-+$/g, '');
-}
-
-function joinPath(...parts) {
- const clean = parts.join('/').split('/').filter(p => p && p !== '.');
- return `/${clean.join('/')}`;
-}
-
-async function fbRename(fromPath, toPath) {
- const qs = new URLSearchParams({ source: FB_SOURCE }).toString();
- const res = await fetch(`${FB_URL}/api/resources?${qs}`, {
- method: 'PATCH',
- headers: { Authorization: `Bearer ${FB_TOKEN}`, 'Content-Type': 'application/json' },
- body: JSON.stringify({
- action: 'rename',
- items: [{ fromSource: FB_SOURCE, fromPath, toSource: FB_SOURCE, toPath }],
- overwrite: false,
- }),
- });
- if (!res.ok) {
- const text = await res.text();
- throw new Error(`${res.status}: ${text.slice(0, 120)}`);
- }
-}
-
-async function fbMkdir(path) {
- const qs = new URLSearchParams({ source: FB_SOURCE, path, isDir: 'true' }).toString();
- const res = await fetch(`${FB_URL}/api/resources?${qs}`, {
- method: 'POST',
- headers: { Authorization: `Bearer ${FB_TOKEN}` },
- });
- if (!res.ok) {
- const text = await res.text();
- if (!text.includes('already') && !text.includes('exist')) throw new Error(`mkdir ${path}: ${res.status}`);
- }
-}
-
-async function main() {
- const { data: projects, error } = await admin
- .from('projects')
- .select('id, name, company:companies(name)');
-
- if (error) { console.error('Query failed:', error.message); process.exit(1); }
- console.log(`Found ${projects.length} projects`);
-
- for (const p of projects) {
- const company = safeName(p.company?.name || '');
- const project = safeName(p.name || '');
- if (!company || !project) continue;
-
- const projectDir = joinPath(CLIENT_ROOT, company, 'Projects', project);
- const oldPath = joinPath(projectDir, 'Project Files');
- const newPath = joinPath(projectDir, '00 Project Files');
-
- try {
- await fbRename(oldPath, newPath);
- console.log(` ✓ renamed: ${company} / ${project}`);
- } catch (err) {
- // If rename fails (source doesn't exist), ensure new folder exists
- try {
- await fbMkdir(newPath);
- console.log(` + created: ${company} / ${project} (no dot folder found)`);
- } catch (e2) {
- console.log(` ~ exists: ${company} / ${project}`);
- }
- }
- }
-
- console.log('\nDone.');
-}
-
-main().catch(err => { console.error(err); process.exit(1); });
diff --git a/src/App.jsx b/src/App.jsx
index 9fe1851..a25b23f 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -52,8 +52,8 @@ const TeamSubInvoiceDetail = lazy(() => import('./pages/team/TeamSubInvoiceDetai
const SurveyMaker = lazy(() => import('./pages/SurveyMaker'));
const BrandBook = lazy(() => import('./pages/BrandBook'));
const Converters = lazy(() => import('./pages/Converters'));
-const FileSharing = lazy(() => import('./pages/FileSharing'));
const TeamFourgePasswords = lazy(() => import('./pages/team/TeamFourgePasswords'));
+const TeamReports = lazy(() => import('./pages/team/TeamReports'));
const ExternalMyPurchaseOrders = lazy(() => import('./pages/external/ExternalMyPurchaseOrders'));
const ExternalMyInvoices = lazy(() => import('./pages/external/ExternalMyInvoices'));
const ExternalMyInvoiceDetail = lazy(() => import('./pages/external/ExternalMyInvoiceDetail'));
@@ -73,6 +73,16 @@ function RedirectToTask() {
return ;
}
+function RedirectTeamInvoiceDetail() {
+ const { id } = useParams();
+ return ;
+}
+
+function RedirectSubsInvoiceDetail() {
+ const { id } = useParams();
+ return ;
+}
+
function NavigateCompanyDetail() {
const { id } = useParams();
return ;
@@ -100,25 +110,30 @@ export default function App() {
} />
} />
} />
- } />
- } />
+ } />
+ } />
} />
- } />
+ } />
} />
} />
+ } />
+ } />
+ } />
} />
} />
} />
- } />
- } />
} />
+ } />
} />
} />
} />
} />
- } />
- } />
- } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
} />
} />
@@ -130,7 +145,8 @@ export default function App() {
} />
} />
} />
- } />
+ } />
+ } />
} />
} />
diff --git a/src/components/FileAttachment.jsx b/src/components/FileAttachment.jsx
index c616eab..e665990 100644
--- a/src/components/FileAttachment.jsx
+++ b/src/components/FileAttachment.jsx
@@ -1,4 +1,4 @@
-import { useState, useRef } from 'react';
+import { useId, useRef, useState } from 'react';
const MAX_FILES = 20;
const MAX_SIZE_MB = 250;
@@ -10,6 +10,7 @@ const formatSize = (bytes) => {
};
export default function FileAttachment({ files, onChange }) {
+ const inputId = useId();
const [errors, setErrors] = useState([]);
const [dragging, setDragging] = useState(false);
const dragCounter = useRef(0);
@@ -79,8 +80,8 @@ export default function FileAttachment({ files, onChange }) {
transition: 'all 160ms', cursor: 'pointer',
}}
>
-
-
+
+
{dragging ? '📂' : '📎'}
{dragging
diff --git a/src/components/FileBrowser.jsx b/src/components/FileBrowser.jsx
deleted file mode 100644
index 42d127a..0000000
--- a/src/components/FileBrowser.jsx
+++ /dev/null
@@ -1,520 +0,0 @@
-import { useEffect, useMemo, useRef, useState } from 'react';
-import { createPortal } from 'react-dom';
-import LoadingButton from './LoadingButton';
-import SortTh from './SortTh';
-import { supabase } from '../lib/supabase';
-import { popupMenuStyle } from '../lib/popupStyles';
-
-const TABLE_TH = { fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px', border: 'none', background: 'transparent', verticalAlign: 'top' };
-const TABLE_TD = { padding: '5px', fontSize: 13, color: 'var(--text-primary)', border: 'none', background: 'transparent' };
-const LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' };
-const ITEM_STYLE = { display: 'flex', alignItems: 'center', gap: 8, padding: '7px 14px', fontSize: 13, cursor: 'pointer', color: 'var(--text-primary)', background: 'none', border: 'none', width: '100%', textAlign: 'left', borderRadius: 4, whiteSpace: 'nowrap' };
-const SEP = { height: 1, background: 'var(--border)', margin: '4px 0' };
-
-function formatBytes(bytes) {
- const value = Number(bytes || 0);
- if (!value) return '—';
- const units = ['B', 'KB', 'MB', 'GB'];
- const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
- return `${(value / (1024 ** index)).toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
-}
-
-function formatDate(dt) {
- if (!dt) return '—';
- const d = new Date(dt);
- return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + ' ' + d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
-}
-
-function fileIconStyle(ext) {
- const e = ext.toLowerCase();
- if (['jpg','jpeg','png','gif','webp','svg','ico','bmp','tiff','avif','heic'].includes(e)) return { bg: '#16a34a', color: '#fff' };
- if (['mp4','mov','avi','mkv','webm','m4v','wmv','flv'].includes(e)) return { bg: '#7c3aed', color: '#fff' };
- if (['mp3','wav','ogg','flac','aac','m4a'].includes(e)) return { bg: '#db2777', color: '#fff' };
- if (e === 'pdf') return { bg: '#dc2626', color: '#fff' };
- if (['doc','docx','rtf'].includes(e)) return { bg: '#2563eb', color: '#fff' };
- if (['txt','md'].includes(e)) return { bg: '#64748b', color: '#fff' };
- if (['xls','xlsx','csv'].includes(e)) return { bg: '#16a34a', color: '#fff' };
- if (['ppt','pptx'].includes(e)) return { bg: '#ea580c', color: '#fff' };
- if (['zip','rar','7z','tar','gz'].includes(e)) return { bg: '#92400e', color: '#fff' };
- if (['ai','eps'].includes(e)) return { bg: '#ff6c00', color: '#fff' };
- if (['psd','psb'].includes(e)) return { bg: '#001e36', color: '#31a8ff' };
- if (['indd','idml'].includes(e)) return { bg: '#49021f', color: '#ff3366' };
- if (['fig','sketch'].includes(e)) return { bg: '#7c3aed', color: '#fff' };
- return { bg: '#475569', color: '#fff' };
-}
-
-function FileIcon({ entry }) {
- if (entry.type === 'dir') {
- return (
-
- );
- }
- const ext = (entry.name.includes('.') ? entry.name.split('.').pop() : '').toUpperCase().slice(0, 4) || 'FILE';
- const { bg, color } = fileIconStyle(ext);
- return (
-
- {ext}
-
- );
-}
-
-function pathParts(path) { return String(path || '/').split('/').filter(Boolean); }
-function pathTo(index, parts) { return `/${parts.slice(0, index + 1).join('/')}`; }
-function joinVirtualPath(...parts) { return ('/' + parts.join('/')).replace(/\/+/g, '/').replace(/\/$/, '') || '/'; }
-
-export default function FileBrowser({ initialPath = '/', rootPath = '/' }) {
- const [currentPath, setCurrentPath] = useState(initialPath);
- const [entries, setEntries] = useState([]);
- const [parentPath, setParentPath] = useState('/');
- const [canGoUp, setCanGoUp] = useState(false);
- const [loading, setLoading] = useState(true);
- const [working, setWorking] = useState('');
- const [dragging, setDragging] = useState(false);
- const [error, setError] = useState('');
- const [readOnly, setReadOnly] = useState(false);
- const [folderName, setFolderName] = useState('');
- const [showFolderInput, setShowFolderInput] = useState(false);
- const [renamingEntry, setRenamingEntry] = useState(null);
- const [renameValue, setRenameValue] = useState('');
- const [uploadProgress, setUploadProgress] = useState(null);
- const [selected, setSelected] = useState(new Set());
- const [sortKey, setSortKey] = useState('name');
- const [sortDir, setSortDir] = useState('asc');
- const [ctxMenu, setCtxMenu] = useState(null);
- const [clipboard, setClipboard] = useState(null);
- const fileInputRef = useRef(null);
- const folderInputRef = useRef(null);
-
- const breadcrumbs = useMemo(() => pathParts(currentPath), [currentPath]);
-
- useEffect(() => {
- if (!ctxMenu) return;
- const close = () => setCtxMenu(null);
- window.addEventListener('click', close);
- return () => window.removeEventListener('click', close);
- }, [ctxMenu]);
-
- const apiFetch = async (url, options = {}) => {
- const { data: { session } } = await supabase.auth.getSession();
- if (!session?.access_token) throw new Error('Session expired. Please sign in again.');
- const response = await fetch(url, {
- ...options,
- headers: {
- Authorization: `Bearer ${session.access_token}`,
- ...(options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
- ...(options.headers || {}),
- },
- });
- const data = await response.json().catch(() => ({}));
- if (!response.ok) throw new Error(data.error || 'File request failed.');
- return data;
- };
-
- const loadFiles = async (path = currentPath) => {
- setLoading(true); setError(''); setSelected(new Set());
- try {
- const params = new URLSearchParams({ action: 'list', path });
- const data = await apiFetch(`/api/filebrowser?${params}`);
- if (data.configured === false) { setError(data.error || 'File browser not configured.'); return; }
- setEntries(data.entries || []);
- setCurrentPath(data.path || path);
- setParentPath(data.parentPath || '/');
- setCanGoUp(data.canGoUp || false);
- setReadOnly(data.readOnly || false);
- } catch (err) { setError(err.message); }
- finally { setLoading(false); }
- };
-
- useEffect(() => { loadFiles(initialPath); }, [initialPath]); // eslint-disable-line react-hooks/exhaustive-deps
-
- const toggleSort = (col) => {
- if (sortKey === col) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
- else { setSortKey(col); setSortDir(col === 'modified' ? 'desc' : 'asc'); }
- };
-
- const sortedEntries = useMemo(() => {
- return [...entries].sort((a, b) => {
- const aDir = a.type === 'dir'; const bDir = b.type === 'dir';
- if (aDir !== bDir) return aDir ? -1 : 1;
- let av, bv;
- if (sortKey === 'name') { av = (a.name || '').toLowerCase(); bv = (b.name || '').toLowerCase(); }
- else if (sortKey === 'size') { av = Number(a.size || 0); bv = Number(b.size || 0); }
- else if (sortKey === 'modified') { av = new Date(a.mtime || 0).getTime(); bv = new Date(b.mtime || 0).getTime(); }
- else { av = ''; bv = ''; }
- const r = typeof av === 'string' ? av.localeCompare(bv) : (av > bv ? 1 : av < bv ? -1 : 0);
- return sortDir === 'asc' ? r : -r;
- });
- }, [entries, sortKey, sortDir]);
-
- const downloadFile = async (entry) => {
- setWorking(`dl:${entry.path}`);
- try {
- const data = await apiFetch(`/api/filebrowser?action=download&path=${encodeURIComponent(entry.path)}`);
- if (data.url && data.token) {
- const sep = data.url.includes('?') ? '&' : '?';
- const a = document.createElement('a');
- a.href = `${data.url}${sep}auth=${encodeURIComponent(data.token)}`;
- a.download = entry.name; a.target = '_blank'; a.rel = 'noopener noreferrer';
- document.body.appendChild(a); a.click(); document.body.removeChild(a);
- }
- } catch (err) { setError(err.message); }
- finally { setWorking(''); }
- };
-
- const downloadSelected = async () => {
- if (!selected.size) return;
- setWorking('dl-sel');
- try {
- for (const name of selected) {
- const entry = entries.find(e => e.name === name);
- if (entry && entry.type !== 'dir') await downloadFile(entry);
- }
- } finally { setWorking(''); }
- };
-
- const deleteEntry = async (entry) => {
- if (!window.confirm(`Delete "${entry.name}"? This cannot be undone.`)) return;
- setWorking(`del:${entry.path}`);
- try {
- await apiFetch(`/api/filebrowser?action=delete&path=${encodeURIComponent(entry.path)}`, { method: 'DELETE' });
- await loadFiles(currentPath);
- } catch (err) { setError(err.message); }
- finally { setWorking(''); }
- };
-
- const deleteSelected = async () => {
- if (!selected.size || !window.confirm(`Delete ${selected.size} item(s)?`)) return;
- for (const name of selected) {
- const entry = entries.find(e => e.name === name);
- if (!entry) continue;
- await apiFetch(`/api/filebrowser?action=delete&path=${encodeURIComponent(entry.path)}`, { method: 'DELETE' }).catch(() => {});
- }
- setSelected(new Set());
- await loadFiles(currentPath);
- };
-
- const createFolder = async (e) => {
- e.preventDefault();
- if (!folderName.trim()) return;
- setWorking('mkdir');
- try {
- await apiFetch('/api/filebrowser?action=mkdir', { method: 'POST', body: JSON.stringify({ path: currentPath, name: folderName }) });
- setFolderName(''); setShowFolderInput(false);
- await loadFiles(currentPath);
- } catch (err) { setError(err.message); }
- finally { setWorking(''); }
- };
-
- const renameEntry = async (e) => {
- e.preventDefault();
- const newName = renameValue.trim();
- if (!newName || newName === renamingEntry.name) { setRenamingEntry(null); return; }
- setWorking(`ren:${renamingEntry.path}`);
- try {
- await apiFetch('/api/filebrowser?action=rename', { method: 'POST', body: JSON.stringify({ path: renamingEntry.path, name: newName }) });
- setRenamingEntry(null);
- await loadFiles(currentPath);
- } catch (err) { setError(err.message); }
- finally { setWorking(''); }
- };
-
- const pasteClipboard = async () => {
- if (!clipboard) return;
- setWorking('paste');
- try {
- for (const item of clipboard.items) {
- await apiFetch('/api/filebrowser?action=' + (clipboard.mode === 'copy' ? 'copy' : 'move'), {
- method: 'POST',
- body: JSON.stringify({ srcPath: item.path, dstParentPath: currentPath }),
- });
- }
- if (clipboard.mode === 'move') setClipboard(null);
- await loadFiles(currentPath);
- } catch (err) { setError(err.message); }
- finally { setWorking(''); }
- };
-
- async function uploadOneFile(vPath, file, retries = 3) {
- for (let attempt = 1; attempt <= retries; attempt++) {
- try {
- await apiFetch(`/api/filebrowser?action=upload&path=${encodeURIComponent(vPath)}`, {
- method: 'POST', headers: { 'Content-Type': 'application/octet-stream' }, body: file,
- });
- return;
- } catch (err) {
- if (attempt === retries) throw err;
- await new Promise(r => setTimeout(r, attempt * 1000));
- }
- }
- }
-
- const uploadFiles = async (files) => {
- const sel = Array.from(files || []);
- if (!sel.length) return;
- setWorking('upload'); setUploadProgress(0); setError('');
- try {
- for (let i = 0; i < sel.length; i++) {
- await uploadOneFile(joinVirtualPath(currentPath, sel[i].name), sel[i]);
- setUploadProgress(Math.round(((i + 1) / sel.length) * 100));
- }
- } catch (err) { setError(err.message); }
- finally { setWorking(''); setUploadProgress(null); if (fileInputRef.current) fileInputRef.current.value = ''; setDragging(false); await loadFiles(currentPath); }
- };
-
- const uploadFolder = async (files) => {
- const sel = Array.from(files || []).filter(f => f.webkitRelativePath);
- if (!sel.length) return;
- setWorking('upload'); setUploadProgress(0); setError('');
- try {
- const dirsNeeded = new Set();
- for (const file of sel) {
- const parts = file.webkitRelativePath.split('/').slice(0, -1);
- for (let i = 1; i <= parts.length; i++) dirsNeeded.add(parts.slice(0, i).join('/'));
- }
- for (const dir of [...dirsNeeded].sort((a, b) => a.split('/').length - b.split('/').length)) {
- await apiFetch('/api/filebrowser?action=mkdir', { method: 'POST', body: JSON.stringify({ path: currentPath, name: dir.split('/').pop() }) }).catch(() => {});
- }
- for (let i = 0; i < sel.length; i++) {
- await uploadOneFile(joinVirtualPath(currentPath, sel[i].webkitRelativePath), sel[i]);
- setUploadProgress(Math.round(((i + 1) / sel.length) * 100));
- }
- } catch (err) { setError(err.message); }
- finally { setWorking(''); setUploadProgress(null); if (folderInputRef.current) folderInputRef.current.value = ''; await loadFiles(currentPath); }
- };
-
- const handleDragEnter = (e) => { e.preventDefault(); if (!readOnly) setDragging(true); };
- const handleDragOver = (e) => { e.preventDefault(); if (!readOnly) { e.dataTransfer.dropEffect = 'copy'; setDragging(true); } };
- const handleDragLeave = (e) => { e.preventDefault(); if (!e.currentTarget.contains(e.relatedTarget)) setDragging(false); };
- const handleDrop = async (e) => {
- e.preventDefault(); setDragging(false);
- if (readOnly) return;
- const items = Array.from(e.dataTransfer.items || []);
- const fsEntries = items.map(i => i.webkitGetAsEntry?.()).filter(Boolean);
- if (fsEntries.some(en => en.isDirectory)) {
- const readFsEntry = (entry) => new Promise(resolve => {
- if (entry.isFile) { entry.file(file => { Object.defineProperty(file, 'webkitRelativePath', { value: entry.fullPath.replace(/^\//, ''), writable: false, configurable: true }); resolve([file]); }); }
- else if (entry.isDirectory) { const reader = entry.createReader(); const readAll = acc => reader.readEntries(async entries => { if (!entries.length) { resolve(acc); return; } const nested = await Promise.all(entries.map(readFsEntry)); readAll([...acc, ...nested.flat()]); }); readAll([]); }
- else { resolve([]); }
- });
- uploadFolder((await Promise.all(fsEntries.map(readFsEntry))).flat());
- } else {
- if (!e.dataTransfer.files?.length) return;
- uploadFiles(e.dataTransfer.files);
- }
- };
-
- const toggleSelect = (name) => setSelected(prev => { const next = new Set(prev); next.has(name) ? next.delete(name) : next.add(name); return next; });
-
- const openCtxMenu = (e, entry, childPath) => {
- e.preventDefault(); e.stopPropagation();
- setCtxMenu({ x: e.clientX, y: e.clientY, entry, childPath });
- };
-
- return (
-
- {dragging && (
-
-
-
↑
-
Drop files to upload
-
-
- )}
-
- {/* Toolbar */}
-
- {/* Selection actions — absolute top-right like FileSharing */}
- {selected.size > 0 && (
-
- Download ({selected.size})
- {!readOnly && Delete ({selected.size}) }
-
- )}
-
-
0 ? 260 : 0 }}>
-
-
Files
- {uploadProgress !== null &&
Uploading {uploadProgress}% }
- {!readOnly && (
- <>
-
uploadFolder(e.target.files)} {...{ webkitdirectory: '' }} />
-
uploadFiles(e.target.files)} />
- >
- )}
-
-
- Drag and drop files or folders here.
-
- {/* Breadcrumbs */}
-
- loadFiles(rootPath)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 11, fontWeight: 500, letterSpacing: 0.6, textTransform: 'uppercase', color: 'var(--text-primary)', fontFamily: 'inherit', padding: 0 }}>Files
- {breadcrumbs.slice(pathParts(rootPath).length).map((part, i) => {
- const absIdx = pathParts(rootPath).length + i;
- return (
-
- /
- loadFiles(pathTo(absIdx, breadcrumbs))} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 11, fontWeight: 500, letterSpacing: 0.6, textTransform: 'uppercase', color: absIdx === breadcrumbs.length - 1 ? 'var(--accent)' : 'var(--text-primary)', fontFamily: 'inherit', padding: 0 }}>{part}
-
- );
- })}
-
-
-
-
- {uploadProgress !== null && (
-
- )}
-
- {/* File table */}
-
- {error &&
{error}
}
- {loading ? (
-
Loading…
- ) : entries.length === 0 ? (
-
This folder is empty.
- ) : (
-
-
-
-
-
-
-
-
-
-
- 0} onChange={e => setSelected(e.target.checked ? new Set(entries.map(en => en.name)) : new Set())} />
-
- Name
- Size
- Modified
-
-
-
- {sortedEntries.map((entry, idx) => {
- const isDir = entry.type === 'dir';
- const childPath = entry.path;
- const isRenaming = renamingEntry?.path === entry.path;
- const rowBg = idx % 2 === 0 ? 'var(--file-row-alt-bg, rgba(255,255,255,0.02))' : 'transparent';
- return (
- isDir && loadFiles(childPath)}
- onContextMenu={e => openCtxMenu(e, entry, childPath)}
- >
- { e.stopPropagation(); toggleSelect(entry.name); }}>
- toggleSelect(entry.name)} onClick={e => e.stopPropagation()} />
-
-
- {isRenaming ? (
-
- ) : (
-
-
- {isDir ? (
- { e.stopPropagation(); loadFiles(childPath); }} style={{ fontSize: 13, fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'left' }}>{entry.name}
- ) : (
- {entry.name}
- )}
-
- )}
-
- {isDir ? '—' : formatBytes(entry.size)}
- {formatDate(entry.mtime)}
-
- );
- })}
-
-
- )}
-
-
- {/* Context menu — portal to escape stacking context */}
- {ctxMenu && createPortal(
-
e.stopPropagation()} style={{ ...popupMenuStyle, position: 'fixed', left: ctxMenu.x, top: ctxMenu.y, zIndex: 9999, padding: '4px 0', minWidth: 160 }}>
- {ctxMenu.entry?.type === 'dir' && (
-
e.currentTarget.style.background = 'var(--interactive-row-hover)'} onMouseLeave={e => e.currentTarget.style.background = 'none'} onClick={() => { loadFiles(ctxMenu.childPath); setCtxMenu(null); }}>
-
- Open
-
- )}
- {ctxMenu.entry?.type !== 'dir' && (
-
e.currentTarget.style.background = 'var(--interactive-row-hover)'} onMouseLeave={e => e.currentTarget.style.background = 'none'} onClick={() => { downloadFile(ctxMenu.entry); setCtxMenu(null); }}>
-
- Download
-
- )}
- {ctxMenu.entry && !readOnly && (
- <>
-
-
e.currentTarget.style.background = 'var(--interactive-row-hover)'} onMouseLeave={e => e.currentTarget.style.background = 'none'} onClick={() => { setRenamingEntry(ctxMenu.entry); setRenameValue(ctxMenu.entry.name); setCtxMenu(null); }}>
-
- Rename
-
- >
- )}
- {ctxMenu.entry && (
- <>
-
-
e.currentTarget.style.background = 'var(--interactive-row-hover)'} onMouseLeave={e => e.currentTarget.style.background = 'none'}
- onClick={() => {
- const items = selected.has(ctxMenu.entry.name)
- ? [...selected].map(n => { const en = entries.find(e => e.name === n); return { name: n, path: en?.path ?? joinVirtualPath(currentPath, n) }; })
- : [{ name: ctxMenu.entry.name, path: ctxMenu.childPath }];
- setClipboard({ items, mode: 'copy' }); setCtxMenu(null);
- }}>
-
- Copy
-
- {!readOnly && (
-
e.currentTarget.style.background = 'var(--interactive-row-hover)'} onMouseLeave={e => e.currentTarget.style.background = 'none'}
- onClick={() => {
- const items = selected.has(ctxMenu.entry.name)
- ? [...selected].map(n => { const en = entries.find(e => e.name === n); return { name: n, path: en?.path ?? joinVirtualPath(currentPath, n) }; })
- : [{ name: ctxMenu.entry.name, path: ctxMenu.childPath }];
- setClipboard({ items, mode: 'move' }); setCtxMenu(null);
- }}>
-
- Cut
-
- )}
- >
- )}
- {clipboard && (
- <>
-
-
e.currentTarget.style.background = 'var(--interactive-row-hover)'} onMouseLeave={e => e.currentTarget.style.background = 'none'} onClick={() => { pasteClipboard(); setCtxMenu(null); }}>
-
- Paste here{clipboard.items.length > 1 ? ` (${clipboard.items.length})` : ''}
-
- >
- )}
- {!readOnly && ctxMenu.entry && (
- <>
-
-
e.currentTarget.style.background = 'rgba(255,80,80,0.08)'} onMouseLeave={e => e.currentTarget.style.background = 'none'} onClick={() => { setCtxMenu(null); deleteEntry(ctxMenu.entry); }}>
-
- Delete
-
- >
- )}
-
,
- document.body
- )}
-
- );
-}
diff --git a/src/components/FilterDropdown.jsx b/src/components/FilterDropdown.jsx
index 30c638c..dd635c3 100644
--- a/src/components/FilterDropdown.jsx
+++ b/src/components/FilterDropdown.jsx
@@ -15,17 +15,26 @@ export default function FilterDropdown({ value, onChange, options }) {
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
- const current = options.find(o => o.value === value);
return (
-
-
setOpen(o => !o)} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
+
+
setOpen(o => !o)}
+ style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
+ aria-label="Filter"
+ title="Filter"
+ >
- {current?.label}
{open && (
-
+
{options.map(opt => (
-
{ onChange(opt.value); setOpen(false); }} style={{ display: 'block', width: '100%', padding: '7px 14px', textAlign: 'left', background: value === opt.value ? 'rgba(245,165,35,0.08)' : 'transparent', fontSize: 13, color: value === opt.value ? 'var(--accent)' : 'var(--text-primary)', border: 'none', cursor: 'pointer' }}>
+ { onChange(opt.value); setOpen(false); }}
+ style={value === opt.value ? { color: 'var(--accent)' } : undefined}
+ >
{opt.label}
))}
diff --git a/src/components/Layout.jsx b/src/components/Layout.jsx
index d4ad85c..5c3c0c1 100644
--- a/src/components/Layout.jsx
+++ b/src/components/Layout.jsx
@@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from 'react';
import PageLoader from './PageLoader';
+import ProfileAvatar from './ProfileAvatar';
import { NavLink, Link, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
@@ -7,12 +8,12 @@ const ICONS = {
dashboard: ,
requests: ,
projects: ,
- fileSharing: ,
invoices: ,
notes: ,
survey: ,
brandBook: ,
converter: ,
+ reports: ,
passwords: ,
companies: ,
users: ,
@@ -28,14 +29,14 @@ function TeamNav({ onNav }) {
const primaryLinks = [
{ to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
{ to: '/tasks', label: 'Tasks', icon: ICONS.requests },
- { to: '/invoices', label: 'Finances', icon: ICONS.invoices },
- { to: '/file-sharing', label: 'File Sharing', icon: ICONS.fileSharing },
+ { to: '/finances', label: 'Finances', icon: ICONS.invoices },
];
const utilityLinks = [
{ to: '/survey-maker', label: 'Survey Maker', icon: ICONS.survey },
{ to: '/brand-book', label: 'Brand Book Maker', icon: ICONS.brandBook },
{ to: '/converters', label: 'Image Converter', icon: ICONS.converter },
+ { to: '/reports', label: 'Reports', icon: ICONS.reports },
{ to: '/fourge-passwords', label: 'Fourge Passwords', icon: ICONS.passwords },
];
@@ -73,8 +74,7 @@ function ClientNav({ onNav }) {
const links = [
{ to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
{ to: '/tasks', label: 'Tasks', icon: ICONS.requests },
- { to: '/file-sharing', label: 'File Sharing', icon: ICONS.fileSharing },
- { to: '/my-invoices', label: 'Invoices', icon: ICONS.invoices },
+ { to: '/client-invoices', label: 'Invoices', icon: ICONS.invoices },
];
return (
@@ -94,8 +94,7 @@ function ExternalNav({ onNav }) {
const links = [
{ to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
{ to: '/tasks', label: 'Tasks', icon: ICONS.requests },
- { to: '/my-invoices-sub', label: 'Invoices', icon: ICONS.invoices },
- { to: '/file-sharing', label: 'File Sharing', icon: ICONS.fileSharing },
+ { to: '/subs-invoices', label: 'Invoices', icon: ICONS.invoices },
{ to: '/survey-maker', label: 'Survey Maker', icon: ICONS.survey },
{ to: '/brand-book', label: 'Brand Book Maker', icon: ICONS.brandBook },
{ to: '/converters', label: 'Image Converter', icon: ICONS.converter },
@@ -127,7 +126,7 @@ function getInitialTheme() {
return 'dark';
}
-export default function Layout({ children }) {
+export default function Layout({ children, loading = false }) {
const { currentUser, logout } = useAuth();
const navigate = useNavigate();
const location = useLocation();
@@ -144,20 +143,54 @@ export default function Layout({ children }) {
const timeOfDay = hour < 12 ? 'morning' : hour < 17 ? 'afternoon' : 'evening';
const firstName = currentUser?.name?.split(' ')[0] || '';
const isProfileRoute = location.pathname === '/profile' || location.pathname.startsWith('/profile/');
- const isFileSharingRoute = location.pathname === '/file-sharing';
const isTaskDetailRoute = location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/requests/');
+ const isInvoiceDetailRoute =
+ location.pathname.startsWith('/finances/') ||
+ location.pathname.startsWith('/invoices/') ||
+ location.pathname.startsWith('/client-invoices/') ||
+ location.pathname.startsWith('/subs-invoices/') ||
+ location.pathname.startsWith('/my-invoices-sub/') ||
+ location.pathname.startsWith('/sub-invoices/');
const isRequestsRoute = location.pathname === '/tasks' || location.pathname === '/requests' || location.pathname === '/team/tasks' || isTaskDetailRoute;
- const isFinancesRoute = location.pathname === '/invoices' || location.pathname.startsWith('/invoices/') || location.pathname === '/sub-invoices' || location.pathname.startsWith('/sub-invoices/');
- const headerTitle = isProfileRoute ? 'Profile' : isFileSharingRoute ? 'File Sharing' : isFinancesRoute ? 'Finances' : isRequestsRoute && !isTaskDetailRoute ? 'Tasks & Projects' : !isTaskDetailRoute ? `Good ${timeOfDay}${firstName ? `, ${firstName}` : ''}` : null;
+ const isFinancesRoute =
+ location.pathname === '/finances' ||
+ location.pathname.startsWith('/finances/') ||
+ location.pathname === '/invoices' ||
+ location.pathname.startsWith('/invoices/') ||
+ location.pathname === '/sub-invoices' ||
+ location.pathname.startsWith('/sub-invoices/') ||
+ location.pathname === '/client-invoices' ||
+ location.pathname.startsWith('/client-invoices/') ||
+ location.pathname === '/my-invoices' ||
+ location.pathname.startsWith('/my-invoices/') ||
+ location.pathname === '/subs-invoices' ||
+ location.pathname.startsWith('/subs-invoices/') ||
+ location.pathname === '/my-invoices-sub' ||
+ location.pathname.startsWith('/my-invoices-sub/');
+ const isReportsRoute = location.pathname === '/reports';
+ const financeHeaderTitle = currentUser?.role === 'team' ? 'Finances' : 'Invoices';
+ const headerTitle = isProfileRoute ? 'Profile' : isFinancesRoute ? financeHeaderTitle : isReportsRoute ? 'Reports' : isRequestsRoute && !isTaskDetailRoute ? 'Tasks & Projects' : !isTaskDetailRoute && !isInvoiceDetailRoute ? `Good ${timeOfDay}${firstName ? `, ${firstName}` : ''}` : null;
const headerSubtitle = isProfileRoute
? 'Account details and security settings.'
- : isFileSharingRoute
- ? 'Browse, share and manage files.'
- : isFinancesRoute
- ? 'Invoices, expenses and subcontractor POs.'
- : isRequestsRoute && !isTaskDetailRoute
+ : isFinancesRoute
+ ? currentUser?.role === 'external'
+ ? 'Track completed work, invoice history and payment status.'
+ : currentUser?.role === 'client'
+ ? 'View invoices, payment status and receipt history.'
+ : 'Invoices, expenses and subcontractor POs.'
+ : isReportsRoute
+ ? 'Track task versions, billing state and exportable summaries.'
+ : isRequestsRoute && !isTaskDetailRoute
? 'Browse and manage all tasks and projects.'
- : isTaskDetailRoute ? null : "Here's what's happening today.";
+ : isTaskDetailRoute || isInvoiceDetailRoute ? null : "Here's what's happening today.";
+ const detailBackTarget = isTaskDetailRoute
+ ? '/tasks'
+ : currentUser?.role === 'team'
+ ? '/finances'
+ : currentUser?.role === 'client'
+ ? '/client-invoices'
+ : '/subs-invoices';
+ const detailBackLabel = isTaskDetailRoute ? 'Tasks & Projects' : financeHeaderTitle;
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
@@ -217,7 +250,7 @@ export default function Layout({ children }) {
return (
- {navigating &&
}
+ {(navigating || loading) &&
}
{menuOpen &&
setMenuOpen(false)} />}
@@ -245,11 +278,11 @@ export default function Layout({ children }) {
- {isTaskDetailRoute ? (
+ {isTaskDetailRoute || isInvoiceDetailRoute ? (
-
+
-
Tasks & Projects
+
{detailBackLabel}
Return back to previous page
@@ -336,10 +369,7 @@ export default function Layout({ children }) {
setAvatarOpen(o => !o)} aria-label="Account menu" style={{ overflow: 'hidden' }}>
- {currentUser?.avatar_url
- ?
- :
- }
+
{avatarOpen && (
diff --git a/src/components/PageLoader.jsx b/src/components/PageLoader.jsx
index cf0a78a..7d072ee 100644
--- a/src/components/PageLoader.jsx
+++ b/src/components/PageLoader.jsx
@@ -30,7 +30,7 @@ export default function PageLoader({ label = 'Loading', progress = null, scope =
<>
diff --git a/src/components/ProfileAvatar.jsx b/src/components/ProfileAvatar.jsx
new file mode 100644
index 0000000..b8cad7d
--- /dev/null
+++ b/src/components/ProfileAvatar.jsx
@@ -0,0 +1,68 @@
+function normalizeName(value) {
+ return String(value || '').trim().toLowerCase();
+}
+
+export function resolveProfileAvatar({
+ avatarUrl = '',
+ profile = null,
+ profileId = '',
+ name = '',
+ profilesById = null,
+ profilesByName = null,
+}) {
+ if (avatarUrl) return avatarUrl;
+ if (profile?.avatar_url) return profile.avatar_url;
+ if (profileId && profilesById?.get(profileId)?.avatar_url) return profilesById.get(profileId).avatar_url;
+ const normalized = normalizeName(name || profile?.name);
+ if (normalized && profilesByName?.get(normalized)?.avatar_url) return profilesByName.get(normalized).avatar_url;
+ return '';
+}
+
+export default function ProfileAvatar({
+ profile = null,
+ profileId = '',
+ name = '',
+ avatarUrl = '',
+ profilesById = null,
+ profilesByName = null,
+ size = 32,
+ fontSize = 12,
+ fallbackBg = 'var(--accent)',
+ fallbackColor = '#000',
+ alt = '',
+ style = {},
+}) {
+ const resolvedUrl = resolveProfileAvatar({ avatarUrl, profile, profileId, name, profilesById, profilesByName });
+ const initials = String(name || profile?.name || '?')
+ .split(' ')
+ .map((part) => part[0])
+ .slice(0, 2)
+ .join('')
+ .toUpperCase();
+
+ if (resolvedUrl) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {initials || '?'}
+
+ );
+}
diff --git a/src/components/RequestForm.jsx b/src/components/RequestForm.jsx
index 04dd1d0..028f607 100644
--- a/src/components/RequestForm.jsx
+++ b/src/components/RequestForm.jsx
@@ -8,6 +8,7 @@ const defaultDeadline = () => addDaysToDateOnly(getTodayDateOnlyEST(), 3);
const modalLabelStyle = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 6 };
const modalInputStyle = { fontSize: 13, padding: '0 5px', textAlign: 'left', lineHeight: 1 };
const modalTextAreaStyle = { ...modalInputStyle, minHeight: 100, padding: '8px 5px', lineHeight: 1.4 };
+const modalActionButtonStyle = { width: 132, justifyContent: 'center', flex: '0 0 132px' };
const emptyForm = (companyId = '') => ({
companyId,
project: '',
@@ -43,7 +44,11 @@ export default function RequestForm({
initialValues = null,
lockedFields = [],
}) {
- const [form, setForm] = useState(() => ({ ...emptyForm(initialCompanyId), ...(initialValues || {}) }));
+ const [form, setForm] = useState(() => ({
+ ...emptyForm(initialCompanyId),
+ requestedBy: showRequester ? (currentUser?.id || '') : '',
+ ...(initialValues || {}),
+ }));
const prevCompanyIdRef = useRef(initialValues?.companyId || initialCompanyId || null);
const [files, setFiles] = useState([]);
const [existingProjects, setExistingProjects] = useState([]);
@@ -54,16 +59,17 @@ export default function RequestForm({
const [signFamilies, setSignFamilies] = useState([]);
const [signCount, setSignCount] = useState('');
const [signs, setSigns] = useState([]);
+ const [localError, setLocalError] = useState('');
- const isBrandBook = form.serviceType === 'Brand Book';
+ const usesSignFields = form.serviceType === 'Brand Book';
useEffect(() => {
supabase.from('sign_families').select('name').order('sort_order').then(({ data }) => setSignFamilies((data || []).map(r => r.name)));
}, []);
useEffect(() => {
- if (!isBrandBook) { setSignCount(''); setSigns([]); }
- }, [isBrandBook]);
+ if (!usesSignFields) { setSignCount(''); setSigns([]); }
+ }, [usesSignFields]);
useEffect(() => {
const n = Math.max(0, parseInt(signCount) || 0);
@@ -113,9 +119,15 @@ export default function RequestForm({
prevCompanyIdRef.current = companyId;
}, [companyId]); // eslint-disable-line react-hooks/exhaustive-deps
- const set = (field) => (e) => setForm(f => ({ ...f, [field]: e.target.value }));
+ const set = (field) => (e) => {
+ if (localError) setLocalError('');
+ setForm(f => ({ ...f, [field]: e.target.value }));
+ };
- const setSign = (id, field, value) => setSigns(prev => prev.map(s => s.id === id ? { ...s, [field]: value } : s));
+ const setSign = (id, field, value) => {
+ if (localError) setLocalError('');
+ setSigns(prev => prev.map(s => s.id === id ? { ...s, [field]: value } : s));
+ };
const allProjectNames = [
...existingProjects.map(p => p.name),
@@ -143,18 +155,56 @@ export default function RequestForm({
};
const showCompanySelect = companies.length > 1 || showRequester;
+ const requesterOptions = [
+ ...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)` }] : []),
+ ...companyUsers.filter(u => u.id !== currentUser?.id),
+ ];
const handleSubmit = (e) => {
e.preventDefault();
- const requestedByName = currentUser?.name || '';
+ setLocalError('');
+ const selectedRequester = showRequester
+ ? requesterOptions.find(option => option.id === form.requestedBy) || null
+ : null;
+ const requestedBy = showRequester
+ ? (selectedRequester?.id || '')
+ : (currentUser?.id || '');
+ const requestedByName = showRequester
+ ? ((selectedRequester?.name || '').replace(/\s+\(You\)$/, '') || '')
+ : (currentUser?.name || '');
+ let normalizedSignCount = null;
+ let normalizedSigns = [];
+
+ if (usesSignFields) {
+ normalizedSignCount = parseInt(signCount, 10) || null;
+ const formData = new FormData(e.currentTarget);
+ normalizedSigns = Array.from({ length: normalizedSignCount || 0 }, (_, index) => {
+ const stateSign = signs[index] || {};
+ const domValue = formData.get(`sign_info_${index + 1}`);
+ const signName = typeof domValue === 'string'
+ ? domValue.trim()
+ : String(stateSign.signName || '').trim();
+ return {
+ id: stateSign.id || `sign-${index + 1}`,
+ signName,
+ signFamily: stateSign.signFamily || '',
+ };
+ }).filter(sign => sign.signName || sign.signFamily);
+
+ if ((normalizedSignCount || 0) > 0 && normalizedSigns.length !== normalizedSignCount) {
+ setLocalError('Please fill in the sign information for each sign before submitting.');
+ return;
+ }
+ }
+
onSubmit(
{
...form,
companyId,
- requestedBy: currentUser?.id || '',
+ requestedBy,
requestedByName,
- signCount: isBrandBook ? (parseInt(signCount) || null) : null,
- signs: isBrandBook ? signs : [],
+ signCount: usesSignFields ? normalizedSignCount : null,
+ signs: usesSignFields ? normalizedSigns : [],
},
files,
existingProjects
@@ -214,6 +264,24 @@ export default function RequestForm({
+ {showRequester && (
+
+ Requested By *
+
+ {companyId ? 'Select requester...' : 'Select company first'}
+ {requesterOptions.map(user => (
+ {user.name}
+ ))}
+
+
+ )}
+
{/* Row 3: Title/Location + Mark as Hot inline */}
@@ -226,7 +294,7 @@ export default function RequestForm({
- {isBrandBook && (
+ {usesSignFields && (
Sign Count *
)}
- {isBrandBook && signs.length > 0 && (
+ {usesSignFields && signs.length > 0 && (
{signs.map((sign, i) => (
@@ -251,6 +319,7 @@ export default function RequestForm({
Sign Information *
setSign(sign.id, 'signName', e.target.value)}
@@ -264,19 +333,19 @@ export default function RequestForm({
)}
- {isBrandBook ? 'Notes' : 'Description'} *
+ {usesSignFields ? 'Notes' : 'Description'} *
- {error &&
⚠ {error}
}
+ {(localError || error) &&
⚠ {localError || error}
}
-
-
+
+
{saving ? 'Submitting...' : submitLabel}
- {onCancel && Cancel }
+ {onCancel && Cancel }
);
diff --git a/src/components/SubcontractorInvoiceDetailView.jsx b/src/components/SubcontractorInvoiceDetailView.jsx
new file mode 100644
index 0000000..8849644
--- /dev/null
+++ b/src/components/SubcontractorInvoiceDetailView.jsx
@@ -0,0 +1,112 @@
+import SortTh from './SortTh';
+
+function fmt(val) {
+ return `$${Number(val || 0).toFixed(2)}`;
+}
+
+function inferItemType(item) {
+ const match = String(item?.description || '').match(/\bR(\d{2})\b/i);
+ if (match) {
+ return Number(match[1]) <= 0
+ ? { label: 'New', badgeClass: 'badge-initial' }
+ : { label: 'Revision', badgeClass: 'badge-client_revision' };
+ }
+ if (item?.task_id) return { label: 'Task', badgeClass: 'badge-in_progress' };
+ return { label: 'Other', badgeClass: 'badge-initial' };
+}
+
+export default function SubcontractorInvoiceDetailView({
+ error,
+ headerActions,
+ leftCardTitle,
+ leftCardBody,
+ rightCardTitle,
+ rightCardBody,
+ sortKey,
+ sortDir,
+ onSort,
+ items,
+ notes,
+ total,
+}) {
+ return (
+
+ {error ?
{error}
: null}
+
+
+
+
+
{leftCardTitle}
+ {leftCardBody}
+
+
+
+
{rightCardTitle}
+ {headerActions ?
{headerActions}
: null}
+
+ {rightCardBody}
+
+
+
+
+
Line Items
+ {items.length === 0 ? (
+
No line items.
+ ) : (
+
+
+
+
+
+
+
+
+
+
+
+ Type
+ Description
+ Qty
+ Unit Price
+ Total
+
+
+
+ {items.map((item) => {
+ const itemType = inferItemType(item);
+ return (
+
+
+ {itemType.label}
+
+ {item.description}
+ {item.quantity}
+ {fmt(item.unit_price)}
+
+ {fmt(Number(item.unit_price || 0) * Number(item.quantity || 1))}
+
+
+ );
+ })}
+
+
+
+ )}
+
+
+
+ {notes ? (
+
+ ) : null}
+
+
+ );
+}
diff --git a/src/components/SubcontractorInvoiceForm.jsx b/src/components/SubcontractorInvoiceForm.jsx
new file mode 100644
index 0000000..d46e645
--- /dev/null
+++ b/src/components/SubcontractorInvoiceForm.jsx
@@ -0,0 +1,562 @@
+import { useState, useEffect, useRef, useMemo } from 'react';
+import { useNavigate } from 'react-router-dom';
+import LoadingButton from './LoadingButton';
+import { supabase } from '../lib/supabase';
+import { useAuth } from '../context/AuthContext';
+import { sendEmail } from '../lib/email';
+import { isCompletedVersionEligible } from '../lib/invoiceVersionRules';
+
+const INVOICE_TODAY = new Date().toISOString().split('T')[0];
+const REVISION_RATE = 30;
+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 FINANCE_MODAL_INPUT_STYLE = {
+ ...FIELD_INPUT_STYLE,
+ width: '100%',
+ padding: '0 10px',
+ fontSize: 13,
+ color: 'var(--text-primary)',
+ background: 'var(--card-bg-2)',
+ border: '1px solid var(--border)',
+ borderRadius: 6,
+ fontFamily: 'inherit',
+ boxSizing: 'border-box',
+};
+const FINANCE_MODAL_TEXTAREA_STYLE = {
+ ...FINANCE_MODAL_INPUT_STYLE,
+ minHeight: 72,
+ padding: '10px',
+ resize: 'vertical',
+};
+const FINANCE_MODAL_LINE_ITEM_INPUT_STYLE = {
+ ...FINANCE_MODAL_INPUT_STYLE,
+ minHeight: 32,
+ padding: '0 8px',
+};
+const FINANCE_MODAL_NUMBER_INPUT_STYLE = {
+ ...FINANCE_MODAL_LINE_ITEM_INPUT_STYLE,
+ fontVariantNumeric: 'tabular-nums',
+};
+const FINANCE_MODAL_TOTAL_VALUE_STYLE = {
+ fontSize: 24,
+ fontWeight: 500,
+ lineHeight: 1.1,
+ color: 'var(--accent)',
+ fontVariantNumeric: 'tabular-nums',
+};
+
+function asArray(value) {
+ if (Array.isArray(value)) return value;
+ if (value == null) return [];
+ return typeof value === 'object' ? [value] : [];
+}
+
+function versionLabel(version = 0) {
+ return `R${String(version).padStart(2, '0')}`;
+}
+
+function parseVersionFromDescription(description = '') {
+ const match = String(description).match(/\bR(\d{2})\b/i);
+ return match ? Number(match[1]) : 0;
+}
+
+function subcontractorVersionRate(version, newWorkRate) {
+ if (version <= 0) return newWorkRate;
+ if (version === 1) return 0;
+ return REVISION_RATE;
+}
+
+function newItem(description = '', unitPrice = '', quantity = 1, taskId = null, isRevision = false, versionNumber = 0, lineKey = null) {
+ return {
+ id: crypto.randomUUID(),
+ description,
+ unit_price: unitPrice,
+ quantity,
+ task_id: taskId,
+ is_revision: isRevision,
+ version_number: versionNumber,
+ line_key: lineKey || (taskId ? `${taskId}:${versionNumber}` : crypto.randomUUID()),
+ };
+}
+
+function genNumber(count) {
+ const year = new Date().getFullYear();
+ return `INVSUB-${year}-${String(count + 1).padStart(3, '0')}`;
+}
+
+export default function SubcontractorInvoiceForm({ embedded = false, onCancel, onSubmitted }) {
+ const navigate = useNavigate();
+ const { currentUser } = useAuth();
+
+ const [invoiceNumber, setInvoiceNumber] = useState('');
+ const [completedTasks, setCompletedTasks] = useState([]);
+ const [loadingTasks, setLoadingTasks] = useState(true);
+ const [items, setItems] = useState([newItem()]);
+ const [notes, setNotes] = useState('');
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState('');
+ const [lineItemsPulse, setLineItemsPulse] = useState(false);
+ const dragItem = useRef(null);
+ const lineItemsRef = useRef(null);
+
+ const rate = Number(currentUser?.brand_book_rate || 60);
+
+ useEffect(() => {
+ async function load() {
+ if (!currentUser?.id) {
+ setCompletedTasks([]);
+ setLoadingTasks(false);
+ return;
+ }
+ setLoadingTasks(true);
+ setError('');
+ try {
+ const [{ data: tasks, error: tasksError }, { data: existingInvoices, error: invoicesError }, { data: nextNum, error: nextNumError }] = await Promise.all([
+ supabase
+ .from('tasks')
+ .select('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
+ .from('subcontractor_invoices')
+ .select('id, status, items:subcontractor_invoice_items(task_id, description)')
+ .in('status', ['submitted', 'paid']),
+ supabase.rpc('get_next_sub_invoice_number'),
+ ]);
+ if (tasksError) throw tasksError;
+ if (invoicesError) throw invoicesError;
+ if (nextNumError) throw nextNumError;
+
+ setInvoiceNumber(nextNum || genNumber(0));
+
+ const invoicedUnitKeys = new Set(
+ (existingInvoices || []).flatMap((inv) => asArray(inv.items).map((item) => {
+ if (!item.task_id) return null;
+ return `${item.task_id}:${parseVersionFromDescription(item.description)}`;
+ }).filter(Boolean))
+ );
+
+ const taskIds = (tasks || []).map((task) => task.id).filter(Boolean);
+ const { data: submissionRows, error: submissionsError } = taskIds.length > 0
+ ? await supabase
+ .from('submissions')
+ .select('task_id, deliveries(version_number, sent_by, sent_at)')
+ .in('task_id', taskIds)
+ : { data: [], error: null };
+ if (submissionsError) throw submissionsError;
+
+ const deliveriesByTask = new Map();
+ for (const row of (submissionRows || [])) {
+ const taskId = row.task_id;
+ if (!taskId) continue;
+ const bucket = deliveriesByTask.get(taskId) || new Map();
+ for (const delivery of asArray(row.deliveries)) {
+ if ((delivery.sent_by || '').trim().toLowerCase() !== (currentUser.name || '').trim().toLowerCase()) continue;
+ const version = Number(delivery.version_number || 0);
+ if (!bucket.has(version)) bucket.set(version, delivery);
+ }
+ deliveriesByTask.set(taskId, bucket);
+ }
+
+ const units = (tasks || []).flatMap((task) => {
+ 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);
+ } catch (err) {
+ console.error('Failed to load subcontractor invoice tasks:', err);
+ setCompletedTasks([]);
+ setError(err?.message || 'Could not load completed tasks.');
+ } finally {
+ setLoadingTasks(false);
+ }
+ }
+ load();
+ }, [currentUser?.id, currentUser?.name, rate]);
+
+ const addedTaskIds = useMemo(() => new Set(items.map((item) => item.line_key).filter(Boolean)), [items]);
+
+ const focusLineItems = () => {
+ setLineItemsPulse(true);
+ lineItemsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ window.setTimeout(() => setLineItemsPulse(false), 1200);
+ };
+
+ const addTask = (task) => {
+ if (addedTaskIds.has(task.key)) return;
+ const toAdd = [newItem(task.description, task.rate, 1, task.task_id, task.is_revision, task.version_number, task.key)];
+ setItems((prev) => {
+ if (prev.length === 1 && !prev[0].description && !prev[0].unit_price) return toAdd;
+ return [...prev, ...toAdd];
+ });
+ focusLineItems();
+ };
+
+ const updateItem = (id, field, value) => {
+ setItems((prev) => prev.map((item) => (item.id === id ? { ...item, [field]: value } : item)));
+ };
+
+ const removeItem = (id) => setItems((prev) => prev.filter((item) => item.id !== id));
+
+ const handleDrop = (targetIndex) => {
+ if (dragItem.current === null || dragItem.current === targetIndex) {
+ dragItem.current = null;
+ return;
+ }
+ setItems((prev) => {
+ const next = [...prev];
+ const [moved] = next.splice(dragItem.current, 1);
+ next.splice(targetIndex, 0, moved);
+ return next;
+ });
+ dragItem.current = null;
+ };
+
+ const handleCancel = () => {
+ if (saving) return;
+ if (onCancel) {
+ onCancel();
+ return;
+ }
+ navigate('/subs-invoices');
+ };
+
+ const total = items.reduce((sum, item) => sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0), 0);
+
+ const handleSubmit = async () => {
+ const valid = items.filter((item) => String(item.description).trim());
+ if (!valid.length) {
+ setError('Add at least one line item.');
+ return;
+ }
+ setSaving(true);
+ setError('');
+ try {
+ const { data: inv, error: invErr } = await supabase
+ .from('subcontractor_invoices')
+ .insert({
+ profile_id: currentUser.id,
+ invoice_number: invoiceNumber.trim(),
+ status: 'submitted',
+ notes: notes.trim(),
+ submitted_at: new Date().toISOString(),
+ })
+ .select()
+ .single();
+ if (invErr) throw invErr;
+
+ const { error: itemsErr } = await supabase.from('subcontractor_invoice_items').insert(
+ valid.map((item, idx) => ({
+ invoice_id: inv.id,
+ task_id: item.task_id || null,
+ description: String(item.description).trim(),
+ quantity: Number(item.quantity) || 1,
+ unit_price: Number(item.unit_price) || 0,
+ sort_order: idx,
+ }))
+ );
+ if (itemsErr) throw itemsErr;
+
+ const invoiceTotal = valid.reduce((sum, item) => sum + (Number(item.unit_price) || 0) * (Number(item.quantity) || 1), 0);
+ sendEmail('subcontractor_invoice_submitted', 'hello@fourgebranding.com', {
+ subName: currentUser.name,
+ subEmail: currentUser.email || '',
+ invoiceNumber: inv.invoice_number,
+ total: invoiceTotal.toFixed(2),
+ invoiceId: inv.id,
+ }).catch((err) => console.error('Sub invoice notification failed:', err));
+
+ if (onSubmitted) {
+ await onSubmitted(inv);
+ } else {
+ navigate(`/subs-invoices/${inv.id}`);
+ }
+ } catch (err) {
+ setError(err?.message || 'Failed to submit invoice.');
+ setSaving(false);
+ }
+ };
+
+ return (
+
+ {!embedded && (
+ <>
+
← Back to Invoices
+
+
+
+
New Invoice
+
+ Invoice date: {new Date(INVOICE_TODAY).toLocaleDateString()} · {invoiceNumber}
+
+
+
+ >
+ )}
+
+ {embedded ? (
+
+
+
New Invoice
+
+
+
Invoice Number
+
setInvoiceNumber(e.target.value)} style={FINANCE_MODAL_INPUT_STYLE} />
+
+
+
+
Total
+
${total.toFixed(2)}
+
+ {error &&
{error}
}
+
+
+
+
+
+
Completed Tasks
+ {completedTasks.length > 0 && !loadingTasks && (
+
completedTasks.forEach((task) => { if (!addedTaskIds.has(task.key)) addTask(task); })}
+ >
+ + All
+
+ )}
+
+ {loadingTasks ? (
+
Loading…
+ ) : completedTasks.length === 0 ? (
+
No completed tasks available to invoice.
+ ) : (
+ completedTasks.map((task) => {
+ const alreadyAdded = addedTaskIds.has(task.key);
+ return (
+
+
+
+
+ {task.is_revision ? 'Revision' : 'New'}
+
+
{task.project_name ? `${task.project_name} • ` : ''}{task.title} · {versionLabel(task.version_number)}
+
+
+ {task.version_number === 0
+ ? `$${rate.toFixed(2)}`
+ : task.version_number === 1
+ ? 'Free'
+ : `$${REVISION_RATE.toFixed(2)}`}
+
+
+
!alreadyAdded && addTask(task)}
+ disabled={alreadyAdded}
+ >
+ {alreadyAdded ? 'Added' : '+ Add'}
+
+
+ );
+ })
+ )}
+
+
+
+
Line Items ({items.filter((item) => String(item.description || '').trim()).length})
+
+ {['', 'Type', 'Description', 'Qty', 'Unit Price', 'Total', ''].map((header, index) => (
+
3 ? 'right' : 'left' }}>{header}
+ ))}
+
+ {items.map((item, index) => (
+
e.preventDefault()}
+ onDrop={() => handleDrop(index)}
+ style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 60px 100px 90px 28px', gap: 6, alignItems: 'center', marginBottom: 4 }}
+ >
+
{ dragItem.current = index; e.dataTransfer.effectAllowed = 'move'; }} style={{ cursor: 'grab', color: 'var(--text-muted)', fontSize: 12, textAlign: 'center', userSelect: 'none' }}>⠿
+
+ {item.is_revision ? 'Revision' : 'New'}
+
+
updateItem(item.id, 'description', e.target.value)} />
+
updateItem(item.id, 'quantity', e.target.value)} />
+
updateItem(item.id, 'unit_price', e.target.value)} />
+
${((Number(item.quantity) || 0) * (Number(item.unit_price) || 0)).toFixed(2)}
+
removeItem(item.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--danger)', fontSize: 14, padding: 2 }}>✕
+
+ ))}
+
{ setItems((prev) => [...prev, newItem()]); focusLineItems(); }}>+ Line Item
+
+
+
+ ) : (
+ <>
+ {error &&
{error}
}
+
+
+
+
Completed Tasks
+ {completedTasks.length > 0 && !loadingTasks && (
+
completedTasks.forEach((task) => { if (!addedTaskIds.has(task.key)) addTask(task); })}
+ >
+ + Add All
+
+ )}
+
+ {loadingTasks ? (
+
Loading...
+ ) : completedTasks.length === 0 ? (
+
No completed tasks available to invoice.
+ ) : (
+
+ {completedTasks.map((task) => {
+ const alreadyAdded = addedTaskIds.has(task.key);
+ return (
+
+
+
+ {task.project_name ? `${task.project_name} • ` : ''}{task.title} · {versionLabel(task.version_number)}
+
+
+ {task.version_number === 0
+ ? `New Book · $${rate.toFixed(2)}`
+ : task.version_number === 1
+ ? `Revision ${versionLabel(task.version_number)} · Free`
+ : `Revision ${versionLabel(task.version_number)} · $${REVISION_RATE.toFixed(2)}`}
+
+
+
!alreadyAdded && addTask(task)}
+ disabled={alreadyAdded}
+ >
+ {alreadyAdded ? 'Added' : '+ Add'}
+
+
+ );
+ })}
+
+ )}
+
+
+
+
+
Line Items ({items.filter((item) => String(item.description || '').trim()).length})
+
+
+
+ {['', 'Type', 'Description', 'Qty / Hrs', 'Rate', 'Total', ''].map((header, index) => (
+
3 ? 'right' : 'left' }}>{header}
+ ))}
+
+
+
+ {items.map((item, index) => (
+
e.preventDefault()}
+ onDrop={() => handleDrop(index)}
+ style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 80px 120px 120px 40px', gap: 8, alignItems: 'center' }}
+ >
+
{ dragItem.current = index; e.dataTransfer.effectAllowed = 'move'; }} style={{ cursor: 'grab', color: 'var(--text-muted)', fontSize: 14, textAlign: 'center', userSelect: 'none' }}>⠿
+
+ {item.is_revision ? 'Revision' : 'New'}
+
+
updateItem(item.id, 'description', e.target.value)} style={{ margin: 0 }} />
+
updateItem(item.id, 'quantity', e.target.value)} style={{ margin: 0, textAlign: 'center' }} />
+
updateItem(item.id, 'unit_price', e.target.value)} style={{ margin: 0, textAlign: 'right' }} />
+
+ ${((Number(item.quantity) || 0) * (Number(item.unit_price) || 0)).toFixed(2)}
+
+
removeItem(item.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--danger)', fontSize: 16, padding: 4 }}>✕
+
+ ))}
+
+
+
{ setItems((prev) => [...prev, newItem()]); focusLineItems(); }}>
+ + Add Line Item
+
+
+
+
+
Total
+
${total.toFixed(2)}
+
+
+
+
+
+ >
+ )}
+
+
+
+ Submit Invoice
+
+ Cancel
+
+
+ );
+}
diff --git a/src/hooks/useLiveRefresh.js b/src/hooks/useLiveRefresh.js
new file mode 100644
index 0000000..1202c26
--- /dev/null
+++ b/src/hooks/useLiveRefresh.js
@@ -0,0 +1,11 @@
+import { useCallback, useState } from 'react';
+import { useRefetchOnFocus } from './useRefetchOnFocus';
+import { useRealtimeSubscription } from './useRealtimeSubscription';
+
+export function useLiveRefresh(tables = []) {
+ const [refreshKey, setRefreshKey] = useState(0);
+ const refresh = useCallback(() => setRefreshKey((key) => key + 1), []);
+ useRefetchOnFocus(refresh);
+ useRealtimeSubscription(tables, refresh);
+ return { refreshKey, refresh };
+}
diff --git a/src/index.css b/src/index.css
index 6edcaf8..00c2799 100644
--- a/src/index.css
+++ b/src/index.css
@@ -42,15 +42,58 @@
--overlay-scrim: rgba(0,0,0,0.58);
--popup-shadow: 0 24px 64px rgba(0,0,0,0.5);
--avatar-inner-ring: #111111;
+ --bg-grid-line: rgba(245, 165, 35, 0.07);
+ --bg-grid-glow: rgba(245, 165, 35, 0.08);
+ --bg-grid-streak: rgba(255, 252, 244, 1);
--danger: #ef4444;
--success: #22c55e;
--table-scroll-thumb: rgba(0,0,0,0.1);
--table-scroll-thumb-hover: rgba(0,0,0,0.2);
}
-@keyframes ui-fade-up {
- from { opacity: 0; transform: translateY(4px); }
- to { opacity: 1; transform: translateY(0); }
+@keyframes ambient-grid-flow {
+ 0% {
+ background-position:
+ calc(42px - 4px) calc(-126px - 4px),
+ calc(126px - 4px) calc(100vh + 126px - 4px),
+ calc(-126px - 4px) calc(84px - 4px),
+ calc(100vw + 126px - 4px) calc(168px - 4px),
+ calc(294px - 4px) calc(-210px - 4px),
+ calc(378px - 4px) calc(100vh + 168px - 4px),
+ calc(-168px - 4px) calc(336px - 4px),
+ calc(100vw + 168px - 4px) calc(420px - 4px),
+ calc(546px - 4px) calc(-168px - 4px),
+ calc(714px - 4px) calc(100vh + 210px - 4px);
+ opacity: 0.2;
+ }
+ 50% {
+ background-position:
+ calc(42px - 4px) calc(50vh - 4px),
+ calc(126px - 4px) calc(50vh - 4px),
+ calc(50vw - 4px) calc(84px - 4px),
+ calc(50vw - 4px) calc(168px - 4px),
+ calc(294px - 4px) calc(50vh - 4px),
+ calc(378px - 4px) calc(50vh - 4px),
+ calc(50vw - 4px) calc(336px - 4px),
+ calc(50vw - 4px) calc(420px - 4px),
+ calc(546px - 4px) calc(50vh - 4px),
+ calc(714px - 4px) calc(50vh - 4px);
+ opacity: 0.38;
+ }
+ 100% {
+ background-position:
+ calc(42px - 4px) calc(100vh + 126px - 4px),
+ calc(126px - 4px) calc(-126px - 4px),
+ calc(100vw + 126px - 4px) calc(84px - 4px),
+ calc(-126px - 4px) calc(168px - 4px),
+ calc(294px - 4px) calc(100vh + 210px - 4px),
+ calc(378px - 4px) calc(-168px - 4px),
+ calc(100vw + 168px - 4px) calc(336px - 4px),
+ calc(-168px - 4px) calc(420px - 4px),
+ calc(546px - 4px) calc(100vh + 168px - 4px),
+ calc(714px - 4px) calc(-210px - 4px);
+ opacity: 0.16;
+ }
}
[data-theme="light"] {
@@ -75,6 +118,9 @@
--overlay-scrim: rgba(255,255,255,0.72);
--popup-shadow: 0 16px 42px rgba(0,0,0,0.18);
--avatar-inner-ring: #ffffff;
+ --bg-grid-line: rgba(0, 0, 0, 0.06);
+ --bg-grid-glow: rgba(0, 0, 0, 0.05);
+ --bg-grid-streak: rgba(255, 255, 255, 1);
--table-scroll-thumb: rgba(0,0,0,0.1);
--table-scroll-thumb-hover: rgba(0,0,0,0.2);
}
@@ -90,8 +136,6 @@
}
[data-theme="light"] input::placeholder,
[data-theme="light"] textarea::placeholder { color: #aaa; }
-[data-theme="light"] th { background: #fafafa; }
-[data-theme="light"] tr:hover td { background: #fafafa; }
[data-theme="light"] .assign-select { background: #fff; color: #1a1a1a; }
[data-theme="light"] .badge-not_started { background: #f1f5f9; color: #64748b; border-color: #e2e8f0; }
[data-theme="light"] .badge-in_progress { background: #eff6ff; color: #2563eb; border-color: #bfdbfe; }
@@ -128,9 +172,63 @@ body {
color: var(--text-primary);
font-size: 14px;
line-height: 1.5;
+ position: relative;
+ isolation: isolate;
}
-#root { all: unset; display: block; height: 100%; }
+body::before {
+ content: '';
+ position: fixed;
+ inset: 0;
+ pointer-events: none;
+ z-index: 0;
+ background-image:
+ linear-gradient(to right, var(--bg-grid-line) 1px, transparent 1px),
+ linear-gradient(to bottom, var(--bg-grid-line) 1px, transparent 1px),
+ radial-gradient(circle at 50% 0%, var(--bg-grid-glow) 0%, transparent 62%);
+ background-size: 42px 42px, 42px 42px, 100% 100%;
+ background-position: 0 0, 0 0, center top;
+ mask-image: linear-gradient(180deg, rgba(0,0,0,1) 0%, rgba(0,0,0,0.92) 18%, rgba(0,0,0,0.58) 62%, rgba(0,0,0,0.2) 100%);
+ -webkit-mask-image: linear-gradient(180deg, rgba(0,0,0,1) 0%, rgba(0,0,0,0.92) 18%, rgba(0,0,0,0.58) 62%, rgba(0,0,0,0.2) 100%);
+}
+
+body::after {
+ content: '';
+ position: fixed;
+ inset: 0;
+ pointer-events: none;
+ z-index: 0;
+ background-image:
+ radial-gradient(circle, var(--bg-grid-streak) 0 1.64px, rgba(255,255,255,1) 2.02px, transparent 4.15px),
+ radial-gradient(circle, color-mix(in srgb, var(--bg-grid-streak) 99%, transparent) 0 1.58px, rgba(255,255,255,0.98) 1.96px, transparent 4px),
+ radial-gradient(circle, color-mix(in srgb, var(--bg-grid-streak) 97%, transparent) 0 1.54px, rgba(255,255,255,0.94) 1.9px, transparent 3.88px),
+ radial-gradient(circle, color-mix(in srgb, var(--bg-grid-streak) 95%, transparent) 0 1.5px, rgba(255,255,255,0.9) 1.84px, transparent 3.74px),
+ radial-gradient(circle, color-mix(in srgb, var(--bg-grid-streak) 98%, transparent) 0 1.6px, rgba(255,255,255,0.98) 1.98px, transparent 4.06px),
+ radial-gradient(circle, color-mix(in srgb, var(--bg-grid-streak) 94%, transparent) 0 1.48px, rgba(255,255,255,0.88) 1.8px, transparent 3.66px),
+ radial-gradient(circle, color-mix(in srgb, var(--bg-grid-streak) 96%, transparent) 0 1.52px, rgba(255,255,255,0.92) 1.88px, transparent 3.8px),
+ radial-gradient(circle, color-mix(in srgb, var(--bg-grid-streak) 100%, transparent) 0 1.66px, rgba(255,255,255,1) 2.04px, transparent 4.18px),
+ radial-gradient(circle, color-mix(in srgb, var(--bg-grid-streak) 97%, transparent) 0 1.56px, rgba(255,255,255,0.95) 1.92px, transparent 3.92px),
+ radial-gradient(circle, color-mix(in srgb, var(--bg-grid-streak) 93%, transparent) 0 1.46px, rgba(255,255,255,0.86) 1.76px, transparent 3.56px);
+ background-size: 8px 8px, 8px 8px, 8px 8px, 8px 8px, 8px 8px, 8px 8px, 8px 8px, 8px 8px, 8px 8px, 8px 8px;
+ background-repeat: no-repeat;
+ filter: blur(0.22px);
+ mix-blend-mode: screen;
+ mask-image:
+ linear-gradient(to right, #000 0 1px, transparent 1px 42px),
+ linear-gradient(to bottom, #000 0 1px, transparent 1px 42px),
+ linear-gradient(180deg, rgba(0,0,0,1) 0%, rgba(0,0,0,0.94) 20%, rgba(0,0,0,0.62) 64%, rgba(0,0,0,0.22) 100%);
+ mask-size: 42px 42px, 42px 42px, 100% 100%;
+ mask-position: 0 0, 0 0, 0 0;
+ -webkit-mask-image:
+ linear-gradient(to right, #000 0 1px, transparent 1px 42px),
+ linear-gradient(to bottom, #000 0 1px, transparent 1px 42px),
+ linear-gradient(180deg, rgba(0,0,0,1) 0%, rgba(0,0,0,0.94) 20%, rgba(0,0,0,0.62) 64%, rgba(0,0,0,0.22) 100%);
+ -webkit-mask-size: 42px 42px, 42px 42px, 100% 100%;
+ -webkit-mask-position: 0 0, 0 0, 0 0;
+ animation: ambient-grid-flow 9.5s linear infinite;
+}
+
+#root { all: unset; display: block; height: 100%; position: relative; z-index: 1; }
/* Layout */
.app-layout { display: flex; height: 100vh; overflow: hidden; }
@@ -330,7 +428,6 @@ input.site-header-search[type="text"]:focus { border-color: var(--accent); }
.site-header-search-icon { position: absolute; left: 13px; top: 50%; transform: translateY(-50%); color: var(--text-muted); pointer-events: none; display: flex; }
.site-header-search-icon svg { width: 14px; height: 14px; }
.site-header-dropdown { position: absolute; top: calc(100% + 6px); right: 0; width: 340px; background: var(--sidebar-bg); border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.4); z-index: 300; overflow: hidden; }
-.site-header-dropdown { animation: ui-fade-up var(--motion-fast) var(--motion-ease); }
.site-header-dropdown-group { padding: 8px 12px 4px; font-size: 10px; font-weight: 500; letter-spacing: 0.8px; text-transform: uppercase; color: var(--text-muted); }
.site-header-dropdown-item { display: flex; align-items: center; gap: 10px; padding: 9px 12px; cursor: pointer; font-size: 13px; color: var(--text-primary); border: 1px solid transparent; }
.site-header-dropdown-item:hover { color: #fff; }
@@ -343,7 +440,6 @@ input.site-header-search[type="text"]:focus { border-color: var(--accent); }
.site-header-avatar-btn:hover { opacity: 0.85; }
[data-theme="light"] .site-header-avatar-btn { border-color: #fff; }
.site-header-avatar-menu { position: absolute; top: calc(100% + 8px); right: 0; background: var(--sidebar-bg); border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.4); z-index: 300; min-width: 160px; overflow: hidden; }
-.site-header-avatar-menu { animation: ui-fade-up var(--motion-fast) var(--motion-ease); }
.site-header-avatar-item { padding: 10px 16px; cursor: pointer; font-size: 13px; color: var(--text-primary); display: block; width: 100%; text-align: left; background: none; border: 1px solid transparent; font-family: inherit; }
.site-header-avatar-item:hover { color: #fff; }
.site-header-avatar-divider { height: 1px; background: var(--border); margin: 4px 0; }
@@ -693,41 +789,14 @@ input.site-header-search[type="text"]:focus { border-color: var(--accent); }
font-size: 13px;
}
-/* File Sharing */
-.file-browser {
- width: 100%;
- max-width: none;
- background: var(--card-bg);
- border: 1px solid var(--border);
- border-radius: 4px;
- overflow: hidden;
- position: relative;
- flex: 1;
- min-height: 0;
- display: flex;
- flex-direction: column;
-}
-
-.file-browser-progress {
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- height: 3px;
- z-index: 10;
- background: transparent;
- border-radius: 4px 4px 0 0;
- overflow: hidden;
-}
-
-.file-browser-progress-bar {
+.shared-progress-bar {
height: 100%;
background: var(--accent);
transition: width 0.2s ease;
border-radius: 0;
}
-.file-browser-progress-bar.indeterminate {
+.shared-progress-bar.indeterminate {
width: 40% !important;
animation: progress-slide 1.4s ease-in-out infinite;
}
@@ -737,168 +806,6 @@ input.site-header-search[type="text"]:focus { border-color: var(--accent); }
100% { transform: translateX(350%); }
}
-.file-browser-dragging {
- border-color: var(--accent);
-}
-
-.file-browser-toolbar {
- display: flex;
- justify-content: space-between;
- align-items: center;
- gap: 14px;
- padding: 14px 16px;
- border-bottom: 1px solid var(--border);
- background: var(--card-bg);
-}
-
-.file-browser-breadcrumbs,
-.file-browser-actions {
- display: flex;
- align-items: center;
- gap: 8px;
- flex-wrap: wrap;
-}
-
-.file-breadcrumb {
- border: 1px solid var(--border);
- background: var(--card-bg-2);
- color: var(--text-primary);
- border-radius: 4px;
- padding: 7px 10px;
- font-family: inherit;
- font-size: 12px;
- font-weight: 400;
- cursor: pointer;
-}
-
-.file-breadcrumb::before {
- content: '/';
- color: var(--text-muted);
- margin-right: 6px;
-}
-
-.file-breadcrumb:hover {
- border-color: var(--interactive-hover-border);
-}
-
-.file-browser-create {
- display: grid;
- grid-template-columns: minmax(180px, 340px) auto;
- gap: 8px;
- align-items: center;
- padding: 12px 16px;
- border-bottom: 1px solid var(--border);
- background: var(--card-bg-2);
-}
-
-.file-upload-input {
- display: none;
-}
-
-.file-list {
- flex: 1;
- min-height: 0;
- position: relative;
- overflow-x: auto;
- overflow-y: auto;
-}
-
-.file-row {
- display: grid;
- grid-template-columns: 34px minmax(180px, 2fr) minmax(90px, 120px) minmax(110px, 140px) minmax(220px, 1fr);
- gap: 10px;
- align-items: center;
- width: 100%;
- min-width: 700px;
- min-height: 52px;
- padding: 0 16px;
- border: 0;
- border-bottom: 1px solid var(--border);
- background: transparent;
- color: var(--text-primary);
- font-family: inherit;
- text-align: left;
-}
-
-.file-row:last-child {
- border-bottom: 0;
-}
-
-.file-row:not(.file-row-head):hover,
-.file-row-button:hover {
- background: var(--interactive-row-hover);
-}
-
-
-.file-row-head {
- min-height: 38px;
- color: var(--text-muted);
- font-size: 10px;
- font-weight: 400;
- text-transform: uppercase;
- letter-spacing: 0.6px;
- background: var(--card-bg);
-}
-
-.file-row-button {
- cursor: pointer;
-}
-
-.file-icon {
- width: 28px;
- height: 28px;
- border: 1px solid var(--border);
- border-radius: 4px;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- color: var(--accent);
- background: var(--card-bg-2);
-}
-
-.file-name,
-.file-name-button {
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- color: var(--text-primary);
- font-size: 13px;
- font-weight: 400;
-}
-
-.file-name-button {
- border: 0;
- background: transparent;
- font-family: inherit;
- text-align: left;
- cursor: pointer;
-}
-
-.file-name-button:hover {
- color: var(--accent);
-}
-
-.file-meta {
- color: var(--text-secondary);
- font-size: 12px;
- text-align: right;
-}
-
-.file-row-actions {
- display: flex;
- gap: 6px;
- justify-content: flex-end;
-}
-
-.file-action-btn {
- width: 32px;
- height: 32px;
- padding: 0;
- justify-content: center;
- font-size: 14px;
-}
-
.btn-icon {
background: none;
border: none;
@@ -932,75 +839,6 @@ input.site-header-search[type="text"]:focus { border-color: var(--accent); }
background: var(--interactive-row-hover);
}
-.file-drop-overlay {
- position: absolute;
- inset: 0;
- z-index: 8;
- display: flex;
- align-items: center;
- justify-content: center;
- background: rgba(17, 17, 17, 0.72);
- border: 2px dashed var(--accent);
- pointer-events: none;
-}
-
-.file-drop-panel {
- min-width: 280px;
- max-width: min(420px, calc(100% - 40px));
- border: 1px solid rgba(245, 165, 35, 0.45);
- border-radius: 4px;
- background: var(--card-bg);
- padding: 28px;
- text-align: center;
- box-shadow: 0 18px 60px rgba(0, 0, 0, 0.28);
-}
-
-.file-drop-icon {
- width: 46px;
- height: 46px;
- border-radius: 4px;
- background: var(--accent);
- color: #111;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- font-size: 24px;
- font-weight: 400;
- margin-bottom: 14px;
-}
-
-.file-drop-title {
- font-size: 17px;
- font-weight: 400;
- color: var(--text-primary);
-}
-
-.file-drop-subtitle {
- margin-top: 5px;
- font-size: 13px;
- color: var(--text-secondary);
-}
-
-@media (max-width: 760px) {
- .file-browser-toolbar {
- align-items: stretch;
- flex-direction: column;
- }
-
- .file-browser-actions {
- justify-content: flex-start;
- }
-
- .file-browser-create {
- grid-template-columns: 1fr;
- }
-
- .file-row {
- grid-template-columns: 34px minmax(150px, 2fr) 90px 110px minmax(170px, 1fr);
- min-width: 700px;
- }
-}
-
/* Buttons */
.btn {
display: inline-flex; align-items: center; gap: 6px;
@@ -1133,11 +971,10 @@ textarea {
.table-wrapper::-webkit-scrollbar-thumb:hover {
background: var(--table-scroll-thumb-hover);
}
-table { width: 100%; border-collapse: collapse; }
-th { text-align: left; padding: 12px 16px; font-size: 10px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.8px; color: var(--text-muted); background: rgba(255,255,255,0.07); border-bottom: 1px solid var(--border); }
-td { padding: 14px 16px; border-bottom: 1px solid var(--border); font-size: 13px; color: var(--text-primary); }
+table { width: 100%; border-collapse: collapse; table-layout: fixed; }
+th { text-align: left; padding: 0 16px 12px; font-size: 10px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.6px; color: var(--text-muted); background: transparent; border: none; vertical-align: top; }
+td { padding: 5px 16px; border: none; font-size: 13px; color: var(--text-primary); background: transparent; }
-tr:hover td { background: rgba(255,255,255,0.02); }
.table-no-row-hover tr:hover td { background: transparent !important; }
[data-theme="light"] .table-no-row-hover tr:hover td { background: transparent !important; }
.table-no-row-hover tbody tr:hover .table-link:not(:hover) { color: inherit !important; }
@@ -1149,6 +986,17 @@ tr:hover td { background: rgba(255,255,255,0.02); }
cursor: pointer;
display: inline-block;
}
+button.table-link {
+ background: none;
+ border: none;
+ padding: 0;
+ margin: 0;
+ font: inherit;
+ text-align: left;
+ appearance: none;
+ -webkit-appearance: none;
+ box-shadow: none;
+}
.table-link:hover,
.table-link:focus-visible {
color: var(--accent) !important;
@@ -1269,8 +1117,8 @@ textarea { resize: vertical; height: auto; min-height: 100px; padding: 8px 14px;
select option { background: #222; color: #fff; }
/* Auth */
-.auth-page { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #0d0d0d; padding: 20px; }
-.auth-card { background: #1a1a1a; border-radius: 4px; border: 1px solid var(--border); padding: 40px; width: 100%; max-width: 440px; }
+.auth-page { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: transparent; padding: 20px; position: relative; z-index: 1; }
+.auth-card { background: var(--card-bg); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border-radius: 4px; border: 1px solid var(--border); padding: 40px; width: 100%; max-width: 440px; box-shadow: var(--popup-shadow); }
.auth-logo { text-align: center; margin-bottom: 32px; }
.auth-logo h1 { font-size: 22px; font-weight: 400; color: #ffffff; letter-spacing: -0.5px; }
.auth-logo p { font-size: 13px; color: #888; margin-top: 6px; }
@@ -1280,24 +1128,24 @@ select option { background: #222; color: #fff; }
.auth-card input[type="password"],
.auth-card select,
.auth-card textarea {
- background: #222;
- border-color: #333;
- color: #fff;
+ background: var(--card-bg-2);
+ border-color: var(--border);
+ color: var(--text-primary);
}
.auth-card input::placeholder,
-.auth-card textarea::placeholder { color: #555; }
+.auth-card textarea::placeholder { color: var(--text-muted); }
.auth-card input:focus, .auth-card select:focus, .auth-card textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(245,165,35,0.1); }
-.auth-card .btn-outline { border-color: #333; color: #888; }
-.auth-card .btn-outline:hover { background: #222; color: #fff; }
-.auth-divider { display: flex; align-items: center; gap: 12px; margin: 20px 0; color: #444; font-size: 12px; }
+.auth-card .btn-outline { border-color: var(--border); color: var(--text-secondary); }
+.auth-card .btn-outline:hover { background: var(--card-bg-2); color: var(--text-primary); }
+.auth-divider { display: flex; align-items: center; gap: 12px; margin: 20px 0; color: var(--text-muted); font-size: 12px; }
.auth-divider::before, .auth-divider::after { content: ''; flex: 1; height: 1px; background: var(--border); }
.quick-login { margin-top: 20px; padding-top: 20px; border-top: 1px solid var(--border); }
-.quick-login-title { font-size: 10px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.5px; color: #555; margin-bottom: 10px; }
+.quick-login-title { font-size: 10px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); margin-bottom: 10px; }
.quick-login-list { display: flex; flex-direction: column; gap: 6px; }
-.quick-login-btn { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border-radius: 4px; border: 1px solid #2a2a2a; background: #222; cursor: pointer; transition: background 0.15s; text-align: left; width: 100%; }
-.quick-login-btn:hover { background: #2a2a2a; }
-.quick-login-name { font-size: 13px; font-weight: 400; color: #fff; }
-.quick-login-email { font-size: 11px; color: #666; }
+.quick-login-btn { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border-radius: 4px; border: 1px solid var(--border); background: var(--card-bg-2); cursor: pointer; transition: background 0.15s; text-align: left; width: 100%; }
+.quick-login-btn:hover { background: color-mix(in srgb, var(--card-bg-2) 72%, var(--accent) 28%); }
+.quick-login-name { font-size: 13px; font-weight: 400; color: var(--text-primary); }
+.quick-login-email { font-size: 11px; color: var(--text-muted); }
/* Misc */
.back-link { display: inline-flex; align-items: center; gap: 6px; color: var(--text-muted); text-decoration: none; font-size: 12px; font-weight: 500; margin-bottom: 20px; cursor: pointer; border: none; background: none; text-transform: uppercase; letter-spacing: 0.5px; font-family: inherit; }
@@ -1325,6 +1173,104 @@ select option { background: #222; color: #fff; }
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px; }
.detail-item label { font-size: 10px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.6px; color: var(--text-muted); margin-bottom: 4px; }
.detail-item p { font-size: 14px; color: var(--text-primary); font-weight: 500; }
+.invoice-detail-shell {
+ display: flex;
+ flex-direction: column;
+}
+.invoice-detail-stack {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+}
+.invoice-detail-summary-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+ gap: 24px;
+}
+.invoice-detail-card {
+ margin: 0;
+ padding: 18px 21px;
+ border-radius: 8px;
+}
+.invoice-detail-card-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 14px;
+}
+.invoice-detail-card-actions {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 8px;
+ flex-wrap: wrap;
+ flex-shrink: 0;
+}
+.invoice-detail-section-title {
+ margin-bottom: 16px;
+ font-size: 11px;
+ font-weight: 500;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.8px;
+}
+.invoice-detail-meta-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+ gap: 18px 24px;
+ margin-bottom: 0;
+}
+.invoice-detail-meta-item label {
+ display: block;
+ margin-bottom: 4px;
+ font-size: 11px;
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.8px;
+ color: var(--text-secondary);
+}
+.invoice-detail-meta-item p {
+ font-size: 13px;
+ line-height: 1.45;
+ color: var(--text-primary);
+ font-weight: 400;
+}
+.invoice-detail-table tbody td {
+ vertical-align: top;
+}
+.invoice-detail-cell {
+ padding: 5px 0;
+ font-size: 13px;
+}
+.invoice-detail-total-row {
+ display: flex;
+ justify-content: flex-end;
+ padding-top: 16px;
+ margin-top: 12px;
+ border-top: 1px solid var(--border);
+ flex-shrink: 0;
+}
+.invoice-detail-total-label {
+ margin-bottom: 4px;
+ font-size: 11px;
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.8px;
+ color: var(--text-secondary);
+}
+.invoice-detail-total-value {
+ font-size: 24px;
+ line-height: 1.1;
+ font-weight: 400;
+ color: var(--accent);
+}
+.invoice-detail-notes {
+ font-size: 14px;
+ color: var(--text-secondary);
+ line-height: 1.6;
+ white-space: pre-wrap;
+}
.notification { padding: 12px 16px; border-radius: 4px; font-size: 13px; font-weight: 500; margin-bottom: 20px; }
.notification-success { background: rgba(34,197,94,0.1); color: #4ade80; border: 1px solid rgba(34,197,94,0.2); }
.notification-info { background: rgba(37,99,235,0.1); color: #60a5fa; border: 1px solid rgba(37,99,235,0.2); }
@@ -1475,6 +1421,20 @@ select option { background: #222; color: #fff; }
color: var(--text-muted);
}
.action-buttons { display: flex; gap: 8px; flex-wrap: wrap; }
+.modal-action-row {
+ display: flex;
+ gap: 8px;
+ justify-content: flex-end;
+ flex-wrap: nowrap;
+}
+.modal-action-row > .btn,
+.modal-action-row > .btn-icon,
+.modal-action-row > a.btn {
+ width: 132px;
+ min-width: 132px;
+ flex: 0 0 132px;
+ justify-content: center;
+}
.assign-select { height: var(--h-control); padding: 0 10px; border-radius: 4px; border: 1px solid var(--border); font-size: 13px; color: var(--text-primary); background: var(--card-bg-2); cursor: pointer; font-family: inherit; line-height: 1; }
.assign-select option { background: #222; }
.flex { display: flex; }
@@ -1649,6 +1609,8 @@ select option { background: #222; color: #fff; }
/* Stack grids on mobile */
.grid-2 { grid-template-columns: 1fr; }
+ .invoice-detail-summary-grid { grid-template-columns: 1fr; }
+ .invoice-detail-meta-grid { grid-template-columns: 1fr; gap: 16px; }
.stats-grid { grid-template-columns: 1fr 1fr; }
.dash-stat-grid { grid-template-columns: 1fr 1fr; }
.detail-grid { grid-template-columns: 1fr 1fr; }
@@ -1702,6 +1664,8 @@ select option { background: #222; color: #fff; }
/* Shared section tabs (Tasks / Projects / Finances / Detail tabs) */
.section-tab-btn {
font-family: 'Fourge', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+ display: inline-flex;
+ align-items: center;
font-size: 13px;
font-weight: 500;
letter-spacing: 0.2px;
@@ -1713,22 +1677,26 @@ select option { background: #222; color: #fff; }
cursor: pointer;
background: transparent;
color: var(--text-secondary);
+ box-shadow: inset 0 -2px 0 transparent;
transition: all 160ms;
}
.section-tab-btn:hover,
.section-tab-btn:focus-visible {
color: var(--accent);
border-bottom-color: var(--accent);
+ box-shadow: inset 0 -2px 0 var(--accent);
outline: none;
}
button.section-tab-btn:hover,
button.section-tab-btn:focus-visible {
color: var(--accent) !important;
border-bottom-color: var(--accent) !important;
+ box-shadow: inset 0 -2px 0 var(--accent) !important;
}
.section-tab-btn.is-active {
color: var(--text-primary);
border-bottom-color: var(--accent);
+ box-shadow: inset 0 -2px 0 var(--accent);
}
/* Rebuilt hover system (single source of truth) */
diff --git a/src/lib/brandBookEditor.js b/src/lib/brandBookEditor.js
index a739556..ecfc982 100644
--- a/src/lib/brandBookEditor.js
+++ b/src/lib/brandBookEditor.js
@@ -1,4 +1,9 @@
import jsPDF from 'jspdf';
+import { formatLongDate } from './dates';
+
+function formatDate(dateStr) {
+ return formatLongDate(dateStr ? `${dateStr}T12:00:00` : '', '-');
+}
// Letter landscape: 792 x 612 pt
const W = 792;
@@ -8,12 +13,6 @@ const ACCENT = [245, 165, 35];
const DARK = [18, 18, 18];
const HEADER_H = 64;
-function formatDate(dateStr) {
- if (!dateStr) return '-';
- const d = new Date(dateStr + 'T00:00:00');
- return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
-}
-
function formatCoverAddress(address) {
const parts = String(address || '')
.split(',')
@@ -384,7 +383,7 @@ export async function generateBrandBookEditorPDF(data) {
let pageNum = 0;
const rev = String(revision || '01').padStart(2, '0');
const displayDate = bookDate
- ? new Date(bookDate + 'T12:00:00').toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
+ ? formatLongDate(`${bookDate}T12:00:00`, '-')
: '';
// ─── COVER PAGE ──────────────────────────────────────────────────────────────
diff --git a/src/lib/dates.js b/src/lib/dates.js
index 6abdbf6..5e16537 100644
--- a/src/lib/dates.js
+++ b/src/lib/dates.js
@@ -8,6 +8,46 @@ export function formatDateEST(value) {
});
}
+export function formatShortDate(value, fallback = '—') {
+ if (!value) return fallback;
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return fallback;
+ return date.toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ });
+}
+
+export function formatLongDate(value, fallback = '—') {
+ if (!value) return fallback;
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return fallback;
+ return date.toLocaleDateString('en-US', {
+ month: 'long',
+ day: 'numeric',
+ year: 'numeric',
+ });
+}
+
+export function formatShortDateTime(value, fallback = '—') {
+ if (!value) return fallback;
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return fallback;
+ return `${formatShortDate(date, fallback)} ${date.toLocaleTimeString('en-US', {
+ hour: 'numeric',
+ minute: '2-digit',
+ hour12: true,
+ })}`;
+}
+
+export function formatDateAtNoon(value, fallback = '—') {
+ if (!value) return fallback;
+ const date = new Date(`${value}T12:00:00`);
+ if (Number.isNaN(date.getTime())) return fallback;
+ return formatLongDate(date, fallback);
+}
+
export function parseDateOnly(value) {
if (!value) return null;
const [year, month, day] = String(value).split('-').map(Number);
diff --git a/src/lib/filebrowserFolders.js b/src/lib/filebrowserFolders.js
deleted file mode 100644
index f1d108a..0000000
--- a/src/lib/filebrowserFolders.js
+++ /dev/null
@@ -1,114 +0,0 @@
-import { supabase } from './supabase';
-
-const FBQ_FN = `${import.meta.env.VITE_SUPABASE_URL}/functions/v1/fbq-proxy`;
-
-async function fbq(op, path, extra = {}) {
- const { data: { session } } = await supabase.auth.getSession();
- if (!session?.access_token) return;
- const headers = {
- Authorization: `Bearer ${session.access_token}`,
- 'X-Operation': op,
- 'X-Path': path,
- ...extra.headers,
- };
- await fetch(FBQ_FN, { method: 'POST', headers, body: extra.body ?? undefined }).catch(() => {});
-}
-
-export function safeName(v) {
- return String(v || '').trim()
- .replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
- .replace(/\s+/g, ' ')
- .replace(/^-+|-+$/g, '');
-}
-
-export async function createProjectFolder(companyName, projectName) {
- if (!companyName || !projectName) return;
- const co = safeName(companyName);
- const proj = safeName(projectName);
- await fbq('mkdir', `/Clients/${co}/`);
- await fbq('mkdir', `/Clients/${co}/Projects/`);
- await fbq('mkdir', `/Clients/${co}/Projects/${proj}/`);
- await fbq('mkdir', `/Clients/${co}/Projects/${proj}/00 Project Files/`);
-}
-
-export async function createTeamMemberFolder(name) {
- if (!name) return;
- await fbq('mkdir', '/Team/');
- await fbq('mkdir', `/Team/${safeName(name)}/`);
-}
-
-export async function createTaskFolder(companyName, projectName, taskTitle) {
- if (!companyName || !projectName || !taskTitle) return;
- const co = safeName(companyName);
- const proj = safeName(projectName);
- const task = safeName(taskTitle);
- const base = `/Clients/${co}/Projects/${proj}/${task}`;
- const mkdirs = [
- `/Clients/${co}/`,
- `/Clients/${co}/Projects/`,
- `/Clients/${co}/Projects/${proj}/`,
- `/Clients/${co}/Projects/${proj}/00 Project Files/`,
- `${base}/`,
- `${base}/Request Info/`,
- `${base}/Old Books/`,
- ];
- for (const dir of mkdirs) {
- await fbq('mkdir', dir);
- }
-}
-
-export async function createClientFolder(companyName) {
- if (!companyName) return;
- await fbq('mkdir', '/Clients/');
- await fbq('mkdir', `/Clients/${companyName}/`);
-}
-
-export async function backfillClientFolders() {
- const { data } = await supabase.from('companies').select('name');
- if (!data?.length) return;
- await fbq('mkdir', '/Clients/');
- for (const company of data) {
- if (company.name) await fbq('mkdir', `/Clients/${company.name}/`);
- }
-}
-
-export async function uploadFilesToRequestInfo(files, companyName, projectName, taskTitle, versionNumber = 0) {
- if (!files?.length || !companyName || !projectName || !taskTitle) return;
-
- const co = safeName(companyName);
- const proj = safeName(projectName);
- const task = safeName(taskTitle);
- const rev = `R${String(versionNumber).padStart(2, '0')}`;
- const base = `/Clients/${co}/Projects/${proj}/${task}/Request Info/${rev}`;
-
- const mkdirs = [
- `/Clients/${co}/`,
- `/Clients/${co}/Projects/`,
- `/Clients/${co}/Projects/${proj}/`,
- `/Clients/${co}/Projects/${proj}/${task}/`,
- `/Clients/${co}/Projects/${proj}/${task}/Request Info/`,
- `/Clients/${co}/Projects/${proj}/${task}/Working Files/`,
- `/Clients/${co}/Projects/${proj}/${task}/Old Books/`,
- `${base}/`,
- ];
- for (const dir of mkdirs) {
- await fbq('mkdir', dir);
- }
-
- const { data: { session } } = await supabase.auth.getSession();
- if (!session?.access_token) return;
-
- for (const file of files) {
- const filePath = `${base}/${file.name}`;
- const fd = new FormData(); fd.append('file', file);
- await fetch(FBQ_FN, {
- method: 'POST',
- headers: {
- Authorization: `Bearer ${session.access_token}`,
- 'X-Operation': 'upload',
- 'X-Path': filePath,
- },
- body: fd,
- }).catch(() => {});
- }
-}
diff --git a/src/lib/folderSync.js b/src/lib/folderSync.js
new file mode 100644
index 0000000..de6547c
--- /dev/null
+++ b/src/lib/folderSync.js
@@ -0,0 +1,62 @@
+import { supabase } from './supabase';
+
+async function callFolderSync(action, body) {
+ const { data: { session } } = await supabase.auth.getSession();
+ const accessToken = session?.access_token;
+ if (!accessToken) return { ok: false, skipped: true, warning: 'No active session.' };
+
+ const response = await fetch(`/api/filebrowser?action=${encodeURIComponent(action)}`, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(body),
+ });
+
+ const payload = await response.json().catch(() => ({}));
+ if (!response.ok) {
+ throw new Error(payload?.error || 'Folder sync failed.');
+ }
+ return payload;
+}
+
+export async function ensureCompanyFolder({ companyId }) {
+ if (!companyId) return { ok: false, skipped: true };
+ return callFolderSync('ensure-company-folder', { companyId });
+}
+
+export async function renameCompanyFolder({ companyId, oldName, newName }) {
+ if (!companyId || !oldName || !newName) return { ok: false, skipped: true };
+ return callFolderSync('rename-company-folder', { companyId, oldName, newName });
+}
+
+export async function ensureProjectFolder({ projectId }) {
+ if (!projectId) return { ok: false, skipped: true };
+ return callFolderSync('ensure-project-folder', { projectId });
+}
+
+export async function renameProjectFolder({ projectId, oldName, newName }) {
+ if (!projectId || !oldName || !newName) return { ok: false, skipped: true };
+ return callFolderSync('rename-project-folder', { projectId, oldName, newName });
+}
+
+export async function ensureSubcontractorFolder({ profileId }) {
+ if (!profileId) return { ok: false, skipped: true };
+ return callFolderSync('ensure-profile-folder', { profileId });
+}
+
+export async function renameSubcontractorFolder({ profileId, oldName, newName }) {
+ if (!profileId || !oldName || !newName) return { ok: false, skipped: true };
+ return callFolderSync('rename-profile-folder', { profileId, oldName, newName });
+}
+
+export async function ensureRequestFolder({ projectId, taskTitle }) {
+ if (!projectId || !taskTitle) return { ok: false, skipped: true };
+ return callFolderSync('ensure-request-folder', { projectId, taskTitle });
+}
+
+export async function uploadRequestFileToSurvey({ projectId, taskTitle, storagePath, fileName, bucket = 'submissions' }) {
+ if (!projectId || !taskTitle || !storagePath || !fileName) return { ok: false, skipped: true };
+ return callFolderSync('upload-request-file', { projectId, taskTitle, storagePath, fileName, bucket });
+}
diff --git a/src/lib/invoiceVersionRules.js b/src/lib/invoiceVersionRules.js
new file mode 100644
index 0000000..0d6d35e
--- /dev/null
+++ b/src/lib/invoiceVersionRules.js
@@ -0,0 +1,18 @@
+export const CLIENT_APPROVED_OR_BETTER = new Set(['client_approved', 'invoiced', 'paid']);
+
+export function isCompletedVersionEligible(task, versionNumber) {
+ const taskVersion = Number(task?.current_version || 0);
+ const version = Number(versionNumber || 0);
+ if (version < taskVersion) return true;
+ return version === taskVersion && CLIENT_APPROVED_OR_BETTER.has(task?.status);
+}
+
+export function isInitialVersionEligible(task) {
+ if (!task || task.invoiced) return false;
+ return isCompletedVersionEligible(task, 0);
+}
+
+export function getRevisionChargeQuantity(versionNumber) {
+ const version = Number(versionNumber || 0);
+ return version >= 2 ? 1 : 0;
+}
diff --git a/src/lib/requestSubmission.js b/src/lib/requestSubmission.js
index ee00dcf..82c2524 100644
--- a/src/lib/requestSubmission.js
+++ b/src/lib/requestSubmission.js
@@ -1,9 +1,18 @@
import { supabase } from './supabase';
+import { ensureProjectFolder } from './folderSync';
function normalizeProjectName(name = '') {
return String(name).trim().toLowerCase();
}
+function serializeSubmissionSigns(normalizedSigns = []) {
+ return normalizedSigns.map((s, i) => ({
+ sign_number: i + 1,
+ sign_name: s.signName,
+ sign_family: s.signFamily || null,
+ }));
+}
+
export async function findOrCreateProject(companyId, projectName, knownProjects = []) {
const normalized = normalizeProjectName(projectName);
if (!companyId || !normalized) throw new Error('Project company and name are required.');
@@ -31,7 +40,14 @@ export async function findOrCreateProject(companyId, projectName, knownProjects
.select('id, name, company_id, status')
.single();
- if (!insertError && newProject) return newProject;
+ if (!insertError && newProject) {
+ try {
+ await ensureProjectFolder({ projectId: newProject.id });
+ } catch (folderError) {
+ console.error('Project folder sync failed:', folderError);
+ }
+ return newProject;
+ }
if (insertError?.code === '23505') {
const { data: retryProjects, error: retryError } = await supabase
@@ -88,6 +104,20 @@ export async function createInitialSubmissionForRequest({
submittedBy,
submittedByName,
}) {
+ const normalizedSignCount = Number.isFinite(Number(signCount)) ? Number(signCount) : null;
+ const normalizedSigns = Array.isArray(signs)
+ ? signs
+ .map((sign = {}) => ({
+ signName: String(sign.signName || '').trim(),
+ signFamily: String(sign.signFamily || '').trim(),
+ }))
+ .filter(sign => sign.signName || sign.signFamily)
+ : [];
+
+ if ((normalizedSignCount || 0) > 0 && normalizedSigns.length !== normalizedSignCount) {
+ throw new Error('Please fill in the sign information for each sign before submitting.');
+ }
+
const { data: submission, error } = await supabase
.from('submissions')
.insert({
@@ -98,7 +128,8 @@ export async function createInitialSubmissionForRequest({
is_hot: isHot,
service_type: serviceType,
sign_family: signFamily || null,
- sign_count: signCount || null,
+ sign_count: normalizedSignCount || null,
+ signs: serializeSubmissionSigns(normalizedSigns),
deadline: deadline || null,
description,
submitted_by: submittedBy,
@@ -107,19 +138,7 @@ export async function createInitialSubmissionForRequest({
.select()
.single();
- if (!error && submission) {
- if (signs?.length > 0) {
- await supabase.from('submission_signs').insert(
- signs.map((s, i) => ({
- submission_id: submission.id,
- sign_number: i + 1,
- sign_name: s.signName,
- sign_family: s.signFamily || null,
- }))
- );
- }
- return { submission, duplicate: false };
- }
+ if (!error && submission) return { submission, duplicate: false };
if (error?.code === '23505' && requestKey) {
const { data: existingSubmission, error: existingError } = await supabase
@@ -128,7 +147,17 @@ export async function createInitialSubmissionForRequest({
.eq('request_key', requestKey)
.single();
if (existingError) throw existingError;
- if (existingSubmission) return { submission: existingSubmission, duplicate: true };
+ if (existingSubmission) {
+ const existingSigns = Array.isArray(existingSubmission.signs) ? existingSubmission.signs : [];
+ if (normalizedSigns.length > 0 && existingSigns.length === 0) {
+ const { error: repairError } = await supabase
+ .from('submissions')
+ .update({ signs: serializeSubmissionSigns(normalizedSigns) })
+ .eq('id', existingSubmission.id);
+ if (repairError) throw repairError;
+ }
+ return { submission: existingSubmission, duplicate: true };
+ }
}
throw error || new Error('Failed to create submission.');
diff --git a/src/lib/submissionDisplay.js b/src/lib/submissionDisplay.js
new file mode 100644
index 0000000..9209ef5
--- /dev/null
+++ b/src/lib/submissionDisplay.js
@@ -0,0 +1,16 @@
+export function mergeSubmissionDisplayNames(submissions = [], profiles = []) {
+ const nameById = new Map(
+ (profiles || [])
+ .filter((profile) => profile?.id)
+ .map((profile) => [profile.id, profile.name || ''])
+ );
+
+ return (submissions || []).map((submission) => ({
+ ...submission,
+ display_submitted_by_name: nameById.get(submission?.submitted_by) || submission?.submitted_by_name || '',
+ }));
+}
+
+export function getSubmissionDisplayName(submission) {
+ return submission?.display_submitted_by_name || submission?.submitted_by_name || '';
+}
diff --git a/src/lib/surveyMakerPdf.js b/src/lib/surveyMakerPdf.js
index af624b3..c18b242 100644
--- a/src/lib/surveyMakerPdf.js
+++ b/src/lib/surveyMakerPdf.js
@@ -1,4 +1,5 @@
import jsPDF from 'jspdf';
+import { formatDateAtNoon } from './dates';
const W = 792;
const H = 612;
@@ -8,9 +9,7 @@ const DARK = [18, 18, 18];
const HEADER_H = 64;
function formatDate(dateStr) {
- if (!dateStr) return '-';
- const d = new Date(`${dateStr}T12:00:00`);
- return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
+ return formatDateAtNoon(dateStr, '-');
}
async function blobToDataUrl(blob) {
diff --git a/src/lib/taskNotifications.js b/src/lib/taskNotifications.js
new file mode 100644
index 0000000..88e9f30
--- /dev/null
+++ b/src/lib/taskNotifications.js
@@ -0,0 +1,42 @@
+import { sendEmail } from './email';
+
+export async function sendTaskStatusUpdate({
+ taskId,
+ taskTitle,
+ projectId,
+ projectName = '',
+ companyId,
+ companyName = '',
+ assignedProfileId,
+ subject,
+ headline,
+ statusLabel,
+ message,
+ buttonLabel = 'View Task',
+ includeTeam = false,
+ includeAssigned = false,
+ includeClient = false,
+ includeProjectMembers = false,
+ recipientStrategy = '',
+}) {
+ if (!taskId || !taskTitle || !statusLabel || !message) return;
+ return sendEmail('task_status_update', [], {
+ taskId,
+ taskTitle,
+ projectId,
+ projectName,
+ companyId,
+ companyName,
+ assignedProfileId,
+ subject,
+ headline,
+ statusLabel,
+ message,
+ buttonLabel,
+ includeTeam,
+ includeAssigned,
+ includeClient,
+ includeProjectMembers,
+ recipientStrategy,
+ });
+}
diff --git a/src/lib/taskUi.js b/src/lib/taskUi.js
new file mode 100644
index 0000000..a7e7f19
--- /dev/null
+++ b/src/lib/taskUi.js
@@ -0,0 +1,70 @@
+import { popupSurfaceStyle } from './popupStyles';
+
+export const TASK_TABLE_TH_STYLE = {
+ fontSize: 10,
+ fontWeight: 500,
+ textTransform: 'uppercase',
+ letterSpacing: 0.6,
+ color: 'var(--text-muted)',
+ textAlign: 'left',
+ padding: '0 0 12px 5px',
+ border: 'none',
+ background: 'transparent',
+ verticalAlign: 'top',
+};
+
+export const TASK_TABLE_TD_BASE = {
+ padding: '5px',
+ border: 'none',
+ background: 'transparent',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+};
+
+export const TASK_MODAL_TITLE_STYLE = {
+ fontSize: 11,
+ fontWeight: 500,
+ textTransform: 'uppercase',
+ letterSpacing: 0.8,
+ color: 'var(--text-secondary)',
+};
+
+export const TASK_MODAL_LABEL_STYLE = {
+ fontSize: 11,
+ fontWeight: 500,
+ color: 'var(--text-secondary)',
+ textTransform: 'uppercase',
+ letterSpacing: 0.8,
+ marginBottom: 6,
+};
+
+export const TASK_MODAL_INPUT_STYLE = {
+ fontSize: 13,
+ padding: '0 5px',
+ textAlign: 'left',
+ lineHeight: 1,
+};
+
+export const TASK_MODAL_BUTTON_STYLE = {
+ lineHeight: 1,
+ display: 'inline-flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+};
+
+export const TASK_MODAL_SHELL_STYLE = {
+ ...popupSurfaceStyle,
+ background: 'var(--card-bg)',
+ width: 'min(720px, 96vw)',
+ maxHeight: 'calc(100vh - 48px)',
+ overflowY: 'auto',
+};
+
+export const PROJECT_MODAL_SHELL_STYLE = {
+ ...popupSurfaceStyle,
+ background: 'var(--card-bg)',
+ width: 'min(434px, 100%)',
+ maxHeight: 'calc(100vh - 48px)',
+ overflowY: 'auto',
+};
diff --git a/src/lib/taskVersions.js b/src/lib/taskVersions.js
new file mode 100644
index 0000000..e78e223
--- /dev/null
+++ b/src/lib/taskVersions.js
@@ -0,0 +1,90 @@
+import { getCurrentVersionForTask, getDeadlineSourceSubmission } from './taskDeadlines';
+import { serviceTypes } from '../data/mockData';
+
+export const REJECT_NOTE_PREFIX = '[rejected-note]';
+export const REVIEW_SHADOW_PREFIX = '[review-shadow]';
+const SERVICE_TYPE_SET = new Set(serviceTypes);
+
+function isRecognizedServiceType(value) {
+ const normalized = String(value || '').trim();
+ return normalized ? SERVICE_TYPE_SET.has(normalized) : false;
+}
+
+export function parseRejectedNote(body = '') {
+ if (!String(body).startsWith(REJECT_NOTE_PREFIX)) return null;
+ const rest = String(body).slice(REJECT_NOTE_PREFIX.length);
+ const [versionRaw, ...noteParts] = rest.split('::');
+ const versionNumber = Number(versionRaw);
+ if (!Number.isFinite(versionNumber)) return null;
+ return { versionNumber, body: noteParts.join('::').trim() };
+}
+
+export function encodeRejectedNote(versionNumber, body) {
+ return `${REJECT_NOTE_PREFIX}${versionNumber}::${body}`;
+}
+
+export function isReviewShadowSubmission(submission) {
+ return String(submission?.description || '').startsWith(REVIEW_SHADOW_PREFIX);
+}
+
+export function isReviewShadowDescription(description = '') {
+ return String(description).startsWith(REVIEW_SHADOW_PREFIX);
+}
+
+export function getVisibleTaskSubmissions(submissions = []) {
+ return (submissions || []).filter(submission => !isReviewShadowSubmission(submission));
+}
+
+export function getLatestVisibleSubmissionForVersion(taskId, submissions = [], versionNumber = 0) {
+ return getVisibleTaskSubmissions(submissions)
+ .filter(submission => submission?.task_id === taskId && (submission?.version_number || 0) === versionNumber)
+ .sort((a, b) => new Date(b.submitted_at || 0).getTime() - new Date(a.submitted_at || 0).getTime())[0] || null;
+}
+
+export function getLatestDeliveryForVersion(taskId, submissions = [], deliveries = [], versionNumber = 0) {
+ const versionSubmissionIds = new Set(
+ (submissions || [])
+ .filter(submission => submission?.task_id === taskId && (submission?.version_number || 0) === versionNumber)
+ .map(submission => submission.id)
+ );
+ if (versionSubmissionIds.size === 0) return null;
+ return (deliveries || [])
+ .filter(delivery => versionSubmissionIds.has(delivery?.submission_id))
+ .sort((a, b) => new Date(b.sent_at || 0).getTime() - new Date(a.sent_at || 0).getTime())[0] || null;
+}
+
+export function getTaskDerivedState(task, submissions = [], deliveries = []) {
+ const taskSubs = (submissions || []).filter(submission => submission?.task_id === task?.id);
+ const visibleTaskSubs = getVisibleTaskSubmissions(taskSubs);
+ const currentVersion = getCurrentVersionForTask(task, taskSubs);
+ const deadlineSource = getDeadlineSourceSubmission(task, taskSubs);
+ const latestVisibleSubmission = getLatestVisibleSubmissionForVersion(task?.id, taskSubs, currentVersion);
+ const latestDelivery = getLatestDeliveryForVersion(task?.id, taskSubs, deliveries, currentVersion);
+ const initialSubmission = visibleTaskSubs.find(submission => submission?.type === 'initial') || null;
+ const canonicalServiceType =
+ (isRecognizedServiceType(initialSubmission?.service_type) && initialSubmission?.service_type) ||
+ visibleTaskSubs.find(submission => isRecognizedServiceType(submission?.service_type))?.service_type ||
+ (isRecognizedServiceType(deadlineSource?.service_type) && deadlineSource?.service_type) ||
+ '—';
+ const deadline = deadlineSource?.deadline || latestVisibleSubmission?.deadline || null;
+ const isHot = Boolean(deadlineSource?.is_hot || latestVisibleSubmission?.is_hot);
+ const latestActivityAt = Math.max(
+ latestVisibleSubmission?.submitted_at ? new Date(latestVisibleSubmission.submitted_at).getTime() : 0,
+ latestDelivery?.sent_at ? new Date(latestDelivery.sent_at).getTime() : 0,
+ task?.submitted_at ? new Date(task.submitted_at).getTime() : 0
+ );
+
+ return {
+ taskSubs,
+ visibleTaskSubs,
+ currentVersion,
+ deadlineSource,
+ latestVisibleSubmission,
+ latestDelivery,
+ initialSubmission,
+ serviceType: canonicalServiceType,
+ deadline,
+ isHot,
+ latestActivityAt,
+ };
+}
diff --git a/src/lib/workScope.js b/src/lib/workScope.js
new file mode 100644
index 0000000..3c2f1e5
--- /dev/null
+++ b/src/lib/workScope.js
@@ -0,0 +1,51 @@
+import { supabase } from './supabase';
+
+export function getCurrentUserCompanyIds(currentUser) {
+ return [
+ ...(currentUser?.companies?.map((company) => company?.company?.id || company?.id) || []),
+ ...(currentUser?.company_id ? [currentUser.company_id] : []),
+ ...(currentUser?.company?.id ? [currentUser.company.id] : []),
+ ].filter(Boolean).filter((value, index, list) => list.indexOf(value) === index);
+}
+
+export async function resolveScopedWorkIds(currentUser, { isClient = false, isExternal = false } = {}) {
+ let scopedCompanyIds = null;
+ let scopedProjectIds = null;
+ let scopedTaskIds = null;
+
+ if (isClient) {
+ scopedCompanyIds = getCurrentUserCompanyIds(currentUser);
+ if (scopedCompanyIds.length > 0) {
+ const { data: projectRows } = await supabase
+ .from('projects')
+ .select('id, tasks(id)')
+ .in('company_id', scopedCompanyIds);
+ scopedProjectIds = (projectRows || []).map((project) => project.id).filter(Boolean);
+ scopedTaskIds = (projectRows || [])
+ .flatMap((project) => (project.tasks || []).map((task) => task.id))
+ .filter(Boolean);
+ } else {
+ scopedProjectIds = [];
+ scopedTaskIds = [];
+ }
+ }
+
+ if (isExternal) {
+ const { data: memberRows } = await supabase
+ .from('project_members')
+ .select('project_id')
+ .eq('profile_id', currentUser?.id);
+ scopedProjectIds = (memberRows || []).map((row) => row.project_id).filter(Boolean);
+ if (scopedProjectIds.length > 0) {
+ const { data: taskRows } = await supabase
+ .from('tasks')
+ .select('id')
+ .in('project_id', scopedProjectIds);
+ scopedTaskIds = (taskRows || []).map((row) => row.id).filter(Boolean);
+ } else {
+ scopedTaskIds = [];
+ }
+ }
+
+ return { scopedCompanyIds, scopedProjectIds, scopedTaskIds };
+}
diff --git a/src/pages/BrandBook.jsx b/src/pages/BrandBook.jsx
index 21b274d..211b03e 100644
--- a/src/pages/BrandBook.jsx
+++ b/src/pages/BrandBook.jsx
@@ -793,10 +793,11 @@ export default function BrandBook() {
{loadingBooks ? (
Loading...
) : filteredBooks.length === 0 ? (
-
-
{savedBooks.length === 0 ? 'No brand books yet' : 'No matching brand books'}
-
{savedBooks.length === 0 ? 'Create your first brand book to get started.' : 'Try clearing the current company filter.'}
-
+ New Brand Book
+
+
+ {savedBooks.length === 0 ? 'No brand books' : 'No matching brand books'}
+
+ {savedBooks.length === 0 &&
+ New Brand Book }
) : (
diff --git a/src/pages/Companies.jsx b/src/pages/Companies.jsx
index 67449e1..9242e00 100644
--- a/src/pages/Companies.jsx
+++ b/src/pages/Companies.jsx
@@ -1,13 +1,14 @@
import { useState, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import Layout from '../components/Layout';
+import PageLoader from '../components/PageLoader';
import SortTh from '../components/SortTh';
import { useSortable } from '../hooks/useSortable';
import { supabase } from '../lib/supabase';
import { useAuth } from '../context/AuthContext';
import { deleteCompanyData } from '../lib/deleteHelpers';
import { readPageCache, writePageCache } from '../lib/pageCache';
-import { createClientFolder, backfillClientFolders, createTeamMemberFolder } from '../lib/filebrowserFolders';
+import { ensureCompanyFolder, ensureSubcontractorFolder, renameSubcontractorFolder } from '../lib/folderSync';
// ── Team view ─────────────────────────────────────────────────────────────────
@@ -63,8 +64,11 @@ function TeamCompanies() {
}).select().single();
setSaving(false);
if (data) {
- createClientFolder(data.name).catch(() => {});
- backfillClientFolders().catch(() => {});
+ try {
+ await ensureCompanyFolder({ companyId: data.id });
+ } catch (folderError) {
+ console.error('Company folder sync failed:', folderError);
+ }
setShowNew(false);
setNewForm({ name: '', phone: '', address: '' });
navigate(`/company/${data.id}`);
@@ -80,8 +84,18 @@ function TeamCompanies() {
const handleEditUserSave = async (userId) => {
if (!editUserVal.trim()) return;
- await supabase.from('profiles').update({ name: editUserVal.trim() }).eq('id', userId);
- setProfiles(prev => prev.map(u => u.id === userId ? { ...u, name: editUserVal.trim() } : u));
+ const existingUser = profiles.find(u => u.id === userId);
+ const oldName = existingUser?.name || '';
+ const newName = editUserVal.trim();
+ await supabase.from('profiles').update({ name: newName }).eq('id', userId);
+ if (existingUser?.role === 'external') {
+ try {
+ await renameSubcontractorFolder({ profileId: userId, oldName, newName });
+ } catch (folderError) {
+ console.error('Subcontractor folder rename failed:', folderError);
+ }
+ }
+ setProfiles(prev => prev.map(u => u.id === userId ? { ...u, name: newName } : u));
setEditingUserId(null);
};
@@ -113,15 +127,31 @@ function TeamCompanies() {
const errBody = error?.context ? await error.context.json().catch(() => null) : null;
const errMsg = errBody?.error || data?.error || error?.message;
if (errMsg) { setUserError(errMsg); return; }
+ if (userForm.role === 'external') {
+ try {
+ const { data: newProfile } = await supabase
+ .from('profiles')
+ .select('id')
+ .eq('email', userForm.email.trim())
+ .eq('role', 'external')
+ .order('created_at', { ascending: false })
+ .limit(1)
+ .maybeSingle();
+ if (newProfile?.id) {
+ await ensureSubcontractorFolder({ profileId: newProfile.id });
+ }
+ } catch (folderError) {
+ console.error('Subcontractor folder sync failed:', folderError);
+ }
+ }
if (userForm.role === 'team' || userForm.role === 'external') {
- createTeamMemberFolder(userForm.name.trim()).catch(() => {});
}
setShowNewUser(false);
setUserForm({ name: '', email: '', password: '', company_id: '', role: 'client' });
load();
};
- if (loading) return
Loading...
;
+ if (loading) return
;
const getProfileCompanyIds = (profile) => {
const ids = new Set(
@@ -209,7 +239,7 @@ function TeamCompanies() {
)}
{companies.length === 0 ? (
-
No clients yet.
+
No clients
) : (
@@ -363,7 +393,7 @@ function TeamCompanies() {
)}
{clientProfiles.length === 0 ? (
- No users yet.
+ No users
) : (
@@ -417,7 +447,7 @@ function TeamCompanies() {
{userSubTab === 'external' && <>
{subcontractors.length === 0 ? (
- No subcontractors yet.
+ No subcontractors
) : (
@@ -484,7 +514,7 @@ function ClientCompanyList() {
{companies.length === 0 ? (
-
No companies linked to your account.
+
No companies
) : (
@@ -512,177 +542,6 @@ function ClientCompanyList() {
);
}
-// ── (removed old ClientCompanies dropdown — kept for reference only) ──────────
-
-function _UnusedClientCompanies() {
- const { currentUser } = useAuth();
- const companies = currentUser?.companies || [];
- const [selectedId, setSelectedId] = useState(companies[0]?.id || null);
- const company = companies.find(c => c.id === selectedId) || companies[0] || null;
-
- const [members, setMembers] = useState([]);
- const [loading, setLoading] = useState(!!company?.id);
- const [editing, setEditing] = useState(false);
- const [form, setForm] = useState({ name: company?.name || '', phone: company?.phone || '', address: company?.address || '' });
- const [saving, setSaving] = useState(false);
-
- useEffect(() => {
- if (!company?.id) return;
- setForm({ name: company.name || '', phone: company.phone || '', address: company.address || '' });
- setEditing(false);
- setLoading(true);
- async function load() {
- const [{ data: primaryMembers }, { data: memberRows }] = await Promise.all([
- supabase.from('profiles').select('id, name, email').eq('company_id', company.id).in('role', ['client', 'external']),
- supabase.from('company_members').select('profile:profiles(id, name, email)').eq('company_id', company.id),
- ]);
- const memberMap = new Map();
- (primaryMembers || []).forEach(m => memberMap.set(m.id, m));
- (memberRows || []).forEach(row => { if (row.profile) memberMap.set(row.profile.id, row.profile); });
- setMembers([...memberMap.values()]);
- setLoading(false);
- }
- load();
- }, [company?.id]);
-
- const handleSave = async (e) => {
- e.preventDefault();
- setSaving(true);
- const { error } = await supabase.from('companies').update({
- name: form.name.trim(),
- phone: form.phone.trim(),
- address: form.address.trim(),
- }).eq('id', company.id);
- setSaving(false);
- if (error) { alert('Failed to save. Please try again.'); return; }
- setEditing(false);
- };
-
- if (!company) return (
-
-
- No company linked to your account.
-
- );
-
- if (loading) return Loading...
;
-
- const companyDetails = [
- { label: 'Company Name', value: form.name || company.name || '—' },
- { label: 'Phone', value: company.phone || '—' },
- { label: 'Address', value: company.address || '—' },
- { label: 'Members', value: String(members.length) },
- ];
-
- return (
-
-
-
- {companies.length > 1 ? (
-
- setSelectedId(e.target.value)}
- style={{
- fontSize: 22, fontWeight: 400, background: 'var(--card-bg)',
- border: '1px solid var(--border)', borderRadius: 4,
- color: 'var(--text-primary)', cursor: 'pointer',
- padding: '4px 8px', fontFamily: 'inherit',
- }}
- >
- {companies.map(c => {c.name} )}
-
-
- ) : (
-
{form.name || company.name}
- )}
-
- {[company.phone, company.address].filter(Boolean).join(' · ') || 'No contact info on file'}
-
-
- {!editing && (
-
setEditing(true)}>Edit Info
- )}
-
-
-
- {companyDetails.map(detail => (
-
-
{detail.value}
-
{detail.label}
-
- ))}
-
-
- {editing && (
-
- )}
-
-
-
People
- {members.length === 0 ? (
-
No members found.
- ) : (
-
- {members.map((member, i) => (
-
-
- {member.name?.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
-
-
-
- {member.name}
- {member.id === currentUser.id && (
- You
- )}
-
-
{member.email || '—'}
-
-
- ))}
-
- )}
-
-
- );
-}
-
// ── Entry point ───────────────────────────────────────────────────────────────
export default function CompaniesPage() {
diff --git a/src/pages/CompanyDetail.jsx b/src/pages/CompanyDetail.jsx
index be7761d..d4c8ba5 100644
--- a/src/pages/CompanyDetail.jsx
+++ b/src/pages/CompanyDetail.jsx
@@ -1,13 +1,14 @@
import { useState, useEffect } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import Layout from '../components/Layout';
+import PageLoader from '../components/PageLoader';
import StatusBadge from '../components/StatusBadge';
import { supabase } from '../lib/supabase';
import { useAuth } from '../context/AuthContext';
import { serviceTypes } from '../data/mockData';
import { cleanupTaskStorage, deleteCompanyData } from '../lib/deleteHelpers';
import { logActivity } from '../lib/activityLog';
-import { createProjectFolder } from '../lib/filebrowserFolders';
+import { ensureProjectFolder, renameCompanyFolder } from '../lib/folderSync';
export default function CompanyDetail() {
const { id } = useParams();
@@ -78,8 +79,14 @@ export default function CompanyDetail() {
if (!nameVal.trim()) return;
setSavingName(true);
const oldName = company.name;
- await supabase.from('companies').update({ name: nameVal.trim() }).eq('id', id);
- setCompany(c => ({ ...c, name: nameVal.trim() }));
+ const newName = nameVal.trim();
+ await supabase.from('companies').update({ name: newName }).eq('id', id);
+ try {
+ await renameCompanyFolder({ companyId: id, oldName, newName });
+ } catch (folderError) {
+ console.error('Company folder rename failed:', folderError);
+ }
+ setCompany(c => ({ ...c, name: newName }));
setEditingName(false);
setSavingName(false);
};
@@ -179,8 +186,12 @@ export default function CompanyDetail() {
status: 'active',
}).select().single();
if (data) {
+ try {
+ await ensureProjectFolder({ projectId: data.id });
+ } catch (folderError) {
+ console.error('Project folder sync failed:', folderError);
+ }
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'project_created', projectId: data.id, projectName: data.name });
- createProjectFolder(company.name, data.name).catch(() => {});
setProjects(prev => [data, ...prev]);
setNewProjectName('');
setShowNewProject(false);
@@ -248,7 +259,7 @@ export default function CompanyDetail() {
setSavingSignFamilyPrice(null);
};
- if (loading) return Loading...
;
+ if (loading) return ;
if (!company) return Company not found.
;
const activeTasks = tasks.filter(t => t.status !== 'client_approved');
@@ -318,15 +329,14 @@ export default function CompanyDetail() {
{/* Tabs */}
-
+
{(isTeam ? ['users', 'projects', 'pricing'] : ['users', 'projects']).map(t => (
setTab(t)}
- className={`tab-btn${tab === t ? ' active' : ''}`}
- style={{ textTransform: 'capitalize' }}
+ className={`section-tab-btn${tab === t ? ' is-active' : ''}`}
>
- {t}
+ {t.charAt(0).toUpperCase() + t.slice(1)}
{t === 'users' && availableUsers.length > 0 && (
{availableUsers.length}
@@ -342,7 +352,7 @@ export default function CompanyDetail() {
Assigned Users
{users.length === 0 ? (
-
No users assigned to this company yet.
+
No users
) : (
{users.map((user, i) => (
@@ -494,10 +504,7 @@ export default function CompanyDetail() {
)}
{projects.length === 0 ? (
-
-
No projects yet
-
Create a project to start adding jobs.
-
+
No projects
) : (
{projects.map(project => {
diff --git a/src/pages/Converters.jsx b/src/pages/Converters.jsx
index 90c7d5e..f68ccff 100644
--- a/src/pages/Converters.jsx
+++ b/src/pages/Converters.jsx
@@ -1,6 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import JSZip from 'jszip';
-import { heicTo, isHeic } from 'heic-to/csp';
import Layout from '../components/Layout';
import LoadingButton from '../components/LoadingButton';
@@ -13,6 +12,14 @@ const OUTPUT_FORMATS = [
const HEIC_EXTENSIONS = new Set(['heic', 'heif']);
const MAX_FILES = 100;
+// Lazy-load heic-to (WASM, ~2.7MB) only when a conversion actually runs,
+// so visiting the page doesn't pull the whole library up front.
+let _heicLibPromise = null;
+function getHeicLib() {
+ if (!_heicLibPromise) _heicLibPromise = import('heic-to/csp');
+ return _heicLibPromise;
+}
+
function getExtension(name = '') {
return name.split('.').pop()?.toLowerCase() || '';
}
@@ -96,6 +103,7 @@ async function convertRasterBlob(blob, format, quality) {
}
async function convertHeicFile(file, format, quality) {
+ const { heicTo } = await getHeicLib();
if (format.mime === 'image/jpeg' || format.mime === 'image/png') {
try {
return await heicTo({ blob: file, type: format.mime, quality });
@@ -129,7 +137,9 @@ async function convertHeicFile(file, format, quality) {
async function convertFile(file, format, quality) {
const looksHeic = isHeicFile(file);
- const confirmedHeic = looksHeic ? await isHeic(file).catch(() => false) : false;
+ const confirmedHeic = looksHeic
+ ? await getHeicLib().then(({ isHeic }) => isHeic(file)).catch(() => false)
+ : false;
if (looksHeic || confirmedHeic) {
return convertHeicFile(file, format, quality);
}
@@ -368,7 +378,7 @@ export default function Converters() {
{!files.length ? (
-
No files added yet.
+
No files
) : (
{files.map((file, index) => {
diff --git a/src/pages/FileSharing.jsx b/src/pages/FileSharing.jsx
deleted file mode 100644
index 635d3fa..0000000
--- a/src/pages/FileSharing.jsx
+++ /dev/null
@@ -1,1247 +0,0 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import JSZip from 'jszip';
-import Layout from '../components/Layout';
-import PageLoader from '../components/PageLoader';
-import SortTh from '../components/SortTh';
-import { supabase } from '../lib/supabase';
-import { useAuth } from '../context/AuthContext';
-import { popupMenuStyle } from '../lib/popupStyles';
-
-const CARD = {
- background: 'var(--card-bg)',
- border: '1px solid var(--border)',
- borderRadius: 8,
- backdropFilter: 'blur(12px)',
- WebkitBackdropFilter: 'blur(12px)',
-};
-
-const LABEL = {
- fontSize: 11,
- fontWeight: 500,
- color: 'var(--text-secondary)',
- letterSpacing: 0.8,
- textTransform: 'uppercase',
- marginBottom: 14,
-};
-
-const TABLE_TH = {
- fontSize: 10,
- fontWeight: 500,
- color: 'var(--text-muted)',
- letterSpacing: 0.6,
- textTransform: 'uppercase',
- padding: '0 0 12px',
- border: 'none',
- background: 'transparent',
- verticalAlign: 'top',
-};
-
-const TABLE_TD = {
- padding: '5px',
- fontSize: 13,
- color: 'var(--text-primary)',
- border: 'none',
- background: 'transparent',
-};
-
-function formatBytes(value) {
- if (!value) return '—';
- const units = ['B', 'KB', 'MB', 'GB'];
- const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), 3);
- return `${(value / 1024 ** index).toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
-}
-
-function formatDate(value) {
- if (!value) return '—';
- const date = new Date(value);
- const dateLabel = date.toLocaleDateString('en-US', {
- month: 'short',
- day: 'numeric',
- year: 'numeric',
- });
- const timeLabel = date.toLocaleTimeString('en-US', {
- hour: 'numeric',
- minute: '2-digit',
- hour12: true,
- });
- return `${dateLabel} ${timeLabel}`;
-}
-
-function extOf(name) {
- const dot = name.lastIndexOf('.');
- return dot > 0 ? name.slice(dot + 1).toUpperCase() : 'FILE';
-}
-
-function fileIconStyle(ext) {
- const kind = ext.toLowerCase();
- if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'avif', 'heic'].includes(kind)) return { bg: '#16a34a', color: '#fff' };
- if (['mp4', 'mov', 'avi', 'mkv', 'webm'].includes(kind)) return { bg: '#7c3aed', color: '#fff' };
- if (['mp3', 'wav', 'flac', 'aac'].includes(kind)) return { bg: '#db2777', color: '#fff' };
- if (kind === 'pdf') return { bg: '#dc2626', color: '#fff' };
- if (['doc', 'docx'].includes(kind)) return { bg: '#2563eb', color: '#fff' };
- if (['xls', 'xlsx', 'csv'].includes(kind)) return { bg: '#16a34a', color: '#fff' };
- if (['ppt', 'pptx'].includes(kind)) return { bg: '#ea580c', color: '#fff' };
- if (['zip', 'rar', '7z'].includes(kind)) return { bg: '#92400e', color: '#fff' };
- if (['ai', 'eps'].includes(kind)) return { bg: '#ff6c00', color: '#fff' };
- if (['psd', 'psb'].includes(kind)) return { bg: '#001e36', color: '#31a8ff' };
- if (['fig', 'sketch'].includes(kind)) return { bg: '#7c3aed', color: '#fff' };
- return { bg: '#475569', color: '#fff' };
-}
-
-function FileIcon({ name, isDir }) {
- if (isDir) {
- return (
-
- );
- }
-
- const ext = extOf(name);
- const { bg, color } = fileIconStyle(ext);
- return (
-
- {ext.slice(0, 4)}
-
- );
-}
-
-async function getAccessTokenOrThrow() {
- const { data: { session } } = await supabase.auth.getSession();
- if (session?.access_token) return session.access_token;
-
- const { data: refreshed, error } = await supabase.auth.refreshSession();
- if (error) throw new Error('Session expired. Please sign in again.');
- if (refreshed?.session?.access_token) return refreshed.session.access_token;
-
- throw new Error('Session expired. Please sign in again.');
-}
-
-async function downloadViaLocalFilebrowser(path, session) {
- const accessToken = session?.access_token || await getAccessTokenOrThrow();
- const response = await fetch(`/api/filebrowser?action=download-blob&path=${encodeURIComponent(path)}&sb_access_token=${encodeURIComponent(accessToken)}`, {
- headers: { Authorization: `Bearer ${accessToken}` },
- });
- if (!response.ok) {
- const text = await response.text();
- throw new Error(text || `Error ${response.status}`);
- }
- return response;
-}
-
-async function downloadViaApiToken(path, filename) {
- const accessToken = await getAccessTokenOrThrow();
- const metaResponse = await fetch(`/api/filebrowser?action=download&path=${encodeURIComponent(path)}&sb_access_token=${encodeURIComponent(accessToken)}`, {
- headers: { Authorization: `Bearer ${accessToken}` },
- });
- if (!metaResponse.ok) {
- const text = await metaResponse.text();
- throw new Error(text || `Error ${metaResponse.status}`);
- }
- const { url, token } = await metaResponse.json();
- if (!url || !token) throw new Error('Download URL unavailable.');
- const urlObj = new URL(url, window.location.origin);
- urlObj.searchParams.set('auth', token);
- const link = document.createElement('a');
- link.href = urlObj.toString();
- link.download = filename;
- link.target = '_blank';
- link.rel = 'noopener noreferrer';
- link.style.display = 'none';
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
-}
-
-async function downloadBlobViaLocalFilebrowser(path) {
- const accessToken = await getAccessTokenOrThrow();
- const response = await downloadViaLocalFilebrowser(path, { access_token: accessToken });
- return response.blob();
-}
-
-async function fbqProxy(op, path, extra = {}) {
- const accessToken = await getAccessTokenOrThrow();
- const authHeaders = { Authorization: `Bearer ${accessToken}` };
-
- if (op === 'list') {
- const response = await fetch(`/api/filebrowser?action=list&path=${encodeURIComponent(path)}&sb_access_token=${encodeURIComponent(accessToken)}`, {
- method: 'GET',
- headers: authHeaders,
- });
- if (!response.ok) {
- const text = await response.text();
- throw new Error(text || `Error ${response.status}`);
- }
- const data = await response.json();
- const entries = Array.isArray(data?.entries) ? data.entries : [];
- const folders = entries
- .filter((entry) => entry.type === 'dir' || entry.type === 'directory' || entry.isDir)
- .map((entry) => ({ name: entry.name, filename: entry.name, path: entry.path, type: 'directory', isDir: true, size: 0, modified: entry.mtime || null }));
- const files = entries
- .filter((entry) => !(entry.type === 'dir' || entry.type === 'directory' || entry.isDir))
- .map((entry) => ({ name: entry.name, filename: entry.name, path: entry.path, type: 'file', isDir: false, size: entry.size || 0, modified: entry.mtime || null }));
- return new Response(JSON.stringify({ folders, files }), { status: 200, headers: { 'Content-Type': 'application/json' } });
- }
-
- if (op === 'download') {
- return downloadViaLocalFilebrowser(path, { access_token: accessToken });
- }
-
- if (op === 'mkdir') {
- const normalized = String(path || '/');
- const parts = normalized.split('/').filter(Boolean);
- const name = parts.pop() || '';
- const parentPath = `/${parts.join('/')}`;
- const response = await fetch(`/api/filebrowser?action=mkdir&sb_access_token=${encodeURIComponent(accessToken)}`, {
- method: 'POST',
- headers: { ...authHeaders, 'Content-Type': 'application/json' },
- body: JSON.stringify({ path: parentPath || '/', name }),
- });
- if (!response.ok) {
- const text = await response.text();
- throw new Error(text || `Error ${response.status}`);
- }
- return response;
- }
-
- if (op === 'upload') {
- const file = extra.body?.get?.('file');
- if (!file) throw new Error('No file provided.');
- const uploadResponse = await fetch(`/api/filebrowser?action=upload&path=${encodeURIComponent(path)}&sb_access_token=${encodeURIComponent(accessToken)}`, {
- method: 'POST',
- headers: { ...authHeaders, 'Content-Type': 'application/octet-stream' },
- body: file,
- });
- if (!uploadResponse.ok) {
- const text = await uploadResponse.text();
- throw new Error(text || `Error ${uploadResponse.status}`);
- }
- return uploadResponse;
- }
-
- if (op === 'delete') {
- const files = JSON.parse(extra.headers?.['X-Files'] || '[]');
- for (const filePath of files) {
- const response = await fetch(`/api/filebrowser?action=delete&path=${encodeURIComponent(filePath)}&sb_access_token=${encodeURIComponent(accessToken)}`, {
- method: 'DELETE',
- headers: authHeaders,
- });
- if (!response.ok) {
- const text = await response.text();
- throw new Error(text || `Error ${response.status}`);
- }
- }
- return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'Content-Type': 'application/json' } });
- }
-
- if (op === 'move' || op === 'copy') {
- const files = JSON.parse(extra.headers?.['X-Files'] || '[]');
- for (const srcPath of files) {
- const response = await fetch(`/api/filebrowser?action=move&sb_access_token=${encodeURIComponent(accessToken)}`, {
- method: 'POST',
- headers: { ...authHeaders, 'Content-Type': 'application/json' },
- body: JSON.stringify({ srcPath, dstPath: path, mode: op }),
- });
- if (!response.ok) {
- const text = await response.text();
- throw new Error(text || `Error ${response.status}`);
- }
- }
- return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'Content-Type': 'application/json' } });
- }
-
- throw new Error(`Unsupported operation: ${op}`);
-}
-
-function safeName(v) {
- return String(v || '').trim().replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-').replace(/\s+/g, ' ').replace(/^-+|-+$/g, '');
-}
-
-function rootPath(user) {
- if (!user || user.role === 'team' || user.role === 'external') return '/';
- const companies = user.companies?.filter(Boolean) ?? [];
- if (companies.length > 1) return '/';
- const name = companies[0]?.name ?? user.company?.name;
- return name ? `/${safeName(name)}` : '/';
-}
-
-function parsePath(path) {
- return path.split('/').filter(Boolean);
-}
-
-function buildPath(parts) {
- return `/${parts.join('/')}`;
-}
-
-const TREE_TOGGLE_W = 18;
-
-export default function FileSharing() {
- const { currentUser } = useAuth();
- const accessScope = useMemo(() => ({
- home: rootPath(currentUser),
- canManage: currentUser?.role === 'team',
- }), [currentUser]);
- const home = accessScope.home;
- const pinsStorageKey = useMemo(() => `fbq_pins:${currentUser?.id ?? 'anon'}`, [currentUser?.id]);
-
- const [path, setPath] = useState(home);
- const [entries, setEntries] = useState([]);
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState('');
- const [pins, setPins] = useState([]);
- const [selected, setSelected] = useState(new Set());
- const [uploading, setUploading] = useState(false);
- const [uploadProgress, setUploadProgress] = useState(null);
- const [downloadingSelection, setDownloadingSelection] = useState(false);
- const [downloadProgress, setDownloadProgress] = useState(null);
- const [ctxMenu, setCtxMenu] = useState(null);
- const [clipboard, setClipboard] = useState(null);
- const [hoveredNavPath, setHoveredNavPath] = useState(null);
- const [dragActive, setDragActive] = useState(false);
- const [sortKey, setSortKey] = useState('name');
- const [sortDir, setSortDir] = useState('asc');
- const [expanded, setExpanded] = useState(() => new Set([home]));
- const [, setNavVersion] = useState(0);
- const navCache = useRef(new Map());
- const dragDepthRef = useRef(0);
- const rootRef = useRef(null);
- const tableScrollRef = useRef(null);
- const tableHeadRef = useRef(null);
- const firstBodyRowRef = useRef(null);
- const [contentHeight, setContentHeight] = useState(null);
- const [tableHeaderHeight, setTableHeaderHeight] = useState(0);
- const [tableRowHeight, setTableRowHeight] = useState(0);
- const [tableViewportHeight, setTableViewportHeight] = useState(0);
-
- useEffect(() => {
- if (!ctxMenu) return undefined;
- const close = () => setCtxMenu(null);
- const onKey = (event) => {
- if (event.key === 'Escape') setCtxMenu(null);
- };
- window.addEventListener('click', close);
- window.addEventListener('keydown', onKey);
- return () => {
- window.removeEventListener('click', close);
- window.removeEventListener('keydown', onKey);
- };
- }, [ctxMenu]);
-
- const load = useCallback(async (nextPath) => {
- setLoading(true);
- setError('');
- setSelected(new Set());
- try {
- const response = await fbqProxy('list', nextPath);
- const data = await response.json();
- setEntries(Array.isArray(data) ? data : [...(data?.folders ?? []), ...(data?.files ?? [])]);
- } catch (err) {
- setError(err?.message || 'Failed to load files.');
- } finally {
- setLoading(false);
- }
- }, []);
-
- useEffect(() => {
- load(path);
- }, [path, load]);
-
- useEffect(() => {
- try {
- setPins(JSON.parse(localStorage.getItem(pinsStorageKey) || '[]'));
- } catch {
- setPins([]);
- }
- }, [pinsStorageKey]);
-
- async function loadNavPath(nextPath) {
- if (navCache.current.has(nextPath)) return;
- navCache.current.set(nextPath, []);
- try {
- const response = await fbqProxy('list', nextPath);
- const data = await response.json();
- const folders = (data?.folders ?? []).map((folder) => folder.name ?? folder.filename ?? '').filter(Boolean);
- navCache.current.set(nextPath, folders);
- setNavVersion((value) => value + 1);
- } catch {
- navCache.current.delete(nextPath);
- }
- }
-
- useEffect(() => {
- loadNavPath(home);
- }, []); // eslint-disable-line react-hooks/exhaustive-deps
-
- useEffect(() => {
- function measure() {
- const root = rootRef.current;
- if (!root) return;
- const main = root.closest('.main-content');
- const header = main?.querySelector('.site-header');
- if (!main || !header) return;
- const styles = window.getComputedStyle(main);
- const paddingTop = parseFloat(styles.paddingTop || '0');
- const paddingBottom = parseFloat(styles.paddingBottom || '0');
- setContentHeight(Math.max(320, main.clientHeight - paddingTop - paddingBottom - header.offsetHeight));
- }
-
- measure();
- window.addEventListener('resize', measure);
- return () => window.removeEventListener('resize', measure);
- }, []);
-
- useEffect(() => {
- function measureTableHeader() {
- setTableHeaderHeight(tableHeadRef.current?.offsetHeight ?? 0);
- setTableRowHeight(firstBodyRowRef.current?.offsetHeight ?? 0);
- }
-
- measureTableHeader();
- window.addEventListener('resize', measureTableHeader);
- return () => window.removeEventListener('resize', measureTableHeader);
- }, [entries.length, loading, sortDir, sortKey]);
-
- useEffect(() => {
- const node = tableScrollRef.current;
- if (!node) return undefined;
-
- const measureViewport = () => setTableViewportHeight(node.clientHeight);
- measureViewport();
-
- const observer = new ResizeObserver(measureViewport);
- observer.observe(node);
- window.addEventListener('resize', measureViewport);
-
- return () => {
- observer.disconnect();
- window.removeEventListener('resize', measureViewport);
- };
- }, [contentHeight, entries.length, loading]);
-
- useEffect(() => {
- const segments = parsePath(path);
- const homeParts = parsePath(home);
- const toExpand = [home];
- for (let index = homeParts.length; index < segments.length; index += 1) {
- toExpand.push(buildPath(segments.slice(0, index + 1)));
- }
- setExpanded((prev) => {
- const next = new Set(prev);
- toExpand.forEach((value) => next.add(value));
- return next;
- });
- toExpand.forEach(loadNavPath);
- loadNavPath(path);
- }, [path]); // eslint-disable-line react-hooks/exhaustive-deps
-
- function toggleNav(nextPath) {
- setExpanded((prev) => {
- const next = new Set(prev);
- if (next.has(nextPath)) {
- next.delete(nextPath);
- } else {
- next.add(nextPath);
- loadNavPath(nextPath);
- }
- return next;
- });
- }
-
- function renderNavTree(nodePath, depth) { // eslint-disable-line no-unused-vars
- const folders = navCache.current.get(nodePath) ?? [];
- if (!folders.length) return null;
- return folders.map((folderName) => {
- const childPath = nodePath === '/' ? `/${folderName}` : `${nodePath}/${folderName}`;
- const isExpanded = expanded.has(childPath);
- return (
-
-
-
{
- event.stopPropagation();
- toggleNav(childPath);
- }}
- style={{ width: 18, height: 28, flexShrink: 0, background: 'none', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-muted)', padding: 0 }}
- >
-
-
-
-
-
navigate(childPath)}
- onContextMenu={(event) => {
- event.preventDefault();
- event.stopPropagation();
- pin(folderName, childPath);
- }}
- onMouseEnter={() => setHoveredNavPath(childPath)}
- onMouseLeave={() => setHoveredNavPath((prev) => (prev === childPath ? null : prev))}
- style={{
- flex: 1,
- background: 'none',
- border: 'none',
- cursor: 'pointer',
- textAlign: 'left',
- padding: '4px 6px 4px 2px',
- borderRadius: 4,
- fontSize: 13,
- fontWeight: 500,
- color: path === childPath || hoveredNavPath === childPath ? 'var(--accent)' : 'var(--text-primary)',
- display: 'flex',
- alignItems: 'center',
- overflow: 'hidden',
- transition: 'color 140ms ease',
- }}
- >
- {folderName}
-
-
- {isExpanded && (
-
-
- {renderNavTree(childPath, depth + 1)}
-
- )}
-
- );
- });
- }
-
- function navigate(nextPath) {
- setPath(nextPath);
- }
-
- function breadcrumbs() {
- const segments = parsePath(path);
- const homeParts = parsePath(home);
- const crumbs = [{ label: 'Home', path: home }];
- for (let index = homeParts.length; index < segments.length; index += 1) {
- crumbs.push({ label: segments[index], path: buildPath(segments.slice(0, index + 1)) });
- }
- return crumbs;
- }
-
- function toggleSelect(name) {
- setSelected((prev) => {
- const next = new Set(prev);
- if (next.has(name)) next.delete(name);
- else next.add(name);
- return next;
- });
- }
-
- function toggleSort(column) {
- if (sortKey === column) {
- setSortDir((prev) => (prev === 'asc' ? 'desc' : 'asc'));
- return;
- }
- setSortKey(column);
- setSortDir(column === 'modified' ? 'desc' : 'asc');
- }
-
- function pin(label, nextPath) {
- const next = [...pins.filter((item) => item.path !== nextPath), { label, path: nextPath }];
- setPins(next);
- localStorage.setItem(pinsStorageKey, JSON.stringify(next));
- }
-
- function unpin(nextPath) {
- const next = pins.filter((item) => item.path !== nextPath);
- setPins(next);
- localStorage.setItem(pinsStorageKey, JSON.stringify(next));
- }
-
- function triggerDownload(url, filename) {
- const link = document.createElement('a');
- link.href = url;
- link.download = filename;
- link.style.display = 'none';
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
- URL.revokeObjectURL(url);
- }
-
- async function handleDownload(entry) {
- const nextPath = entry.path ?? (path === '/' ? `/${entry.name}` : `${path}/${entry.name}`);
- try {
- await downloadViaApiToken(nextPath, entry.name);
- } catch (err) {
- setError(err?.message || 'Download failed.');
- }
- }
-
- async function addPathToZip(zip, targetPath, zipPath = '') {
- const response = await fbqProxy('list', targetPath);
- const data = await response.json();
- const folders = data?.folders ?? [];
- const files = data?.files ?? [];
-
- for (const folder of folders) {
- const folderName = folder.name ?? folder.filename ?? '';
- if (!folderName) continue;
- const childPath = targetPath === '/' ? `/${folderName}` : `${targetPath}/${folderName}`;
- const childZipPath = zipPath ? `${zipPath}/${folderName}` : folderName;
- await addPathToZip(zip, childPath, childZipPath);
- }
-
- for (const file of files) {
- const fileName = file.name ?? file.filename ?? '';
- if (!fileName) continue;
- const filePath = file.path ?? (targetPath === '/' ? `/${fileName}` : `${targetPath}/${fileName}`);
- const blob = await downloadBlobViaLocalFilebrowser(filePath);
- zip.file(zipPath ? `${zipPath}/${fileName}` : fileName, blob);
- }
- }
-
- async function handleDownloadSelected() {
- if (!selected.size) return;
- setDownloadingSelection(true);
- setDownloadProgress(0);
- setError('');
- try {
- const selectedEntries = [...selected]
- .map((name) => entries.find((entry) => (entry.name ?? entry.filename) === name))
- .filter(Boolean);
-
- if (selectedEntries.length === 1) {
- const [entry] = selectedEntries;
- const name = entry.name ?? entry.filename ?? '';
- const targetPath = entry.path ?? (path === '/' ? `/${name}` : `${path}/${name}`);
- const isDir = entry.type === 'directory' || entry.isDir;
-
- if (!isDir) {
- const blob = await downloadBlobViaLocalFilebrowser(targetPath);
- setDownloadProgress(100);
- triggerDownload(URL.createObjectURL(blob), name);
- return;
- }
-
- const zip = new JSZip();
- setDownloadProgress(50);
- await addPathToZip(zip, targetPath, name);
- setDownloadProgress(100);
- triggerDownload(URL.createObjectURL(await zip.generateAsync({ type: 'blob' })), `${name}.zip`);
- return;
- }
-
- const zip = new JSZip();
-
- for (let i = 0; i < selectedEntries.length; i++) {
- const entry = selectedEntries[i];
- const name = entry.name ?? entry.filename ?? '';
- const targetPath = entry.path ?? (path === '/' ? `/${name}` : `${path}/${name}`);
- const isDir = entry.type === 'directory' || entry.isDir;
- if (isDir) {
- await addPathToZip(zip, targetPath, name);
- } else {
- const blob = await downloadBlobViaLocalFilebrowser(targetPath);
- zip.file(name, blob);
- }
- setDownloadProgress(Math.round(((i + 1) / selectedEntries.length) * 100));
- }
-
- const folderName = parsePath(path).at(-1) || 'files';
- triggerDownload(URL.createObjectURL(await zip.generateAsync({ type: 'blob' })), `${folderName}-selection.zip`);
- } catch (err) {
- setError(err?.message || 'Download failed.');
- } finally {
- setDownloadingSelection(false);
- setDownloadProgress(null);
- }
- }
-
- async function handlePaste(destPath) {
- if (!clipboard) return;
- try {
- await fbqProxy(clipboard.mode, destPath, {
- headers: { 'X-Files': JSON.stringify(clipboard.items.map((i) => i.path)) },
- });
- if (clipboard.mode === 'move') setClipboard(null);
- load(path);
- } catch {
- setError('Paste failed.');
- }
- }
-
- async function handleDelete() {
- if (!selected.size) return;
- if (!window.confirm(`Delete ${selected.size} item(s)?`)) return;
- const filePaths = [...selected].map((name) => (
- entries.find((entry) => (entry.name ?? entry.filename) === name)?.path
- ?? (path === '/' ? `/${name}` : `${path}/${name}`)
- ));
- try {
- await fbqProxy('delete', path, { headers: { 'X-Files': JSON.stringify(filePaths) } });
- load(path);
- } catch {
- setError('Delete failed.');
- }
- }
-
- async function uploadFiles(files) {
- if (!files.length) return;
- setUploading(true);
- setUploadProgress(0);
- try {
- const dirSet = new Set();
- files.forEach(({ relativePath }) => {
- const segments = relativePath.split('/').filter(Boolean);
- for (let index = 1; index < segments.length; index += 1) {
- dirSet.add(segments.slice(0, index).join('/'));
- }
- });
-
- const dirs = [...dirSet].sort((left, right) => left.split('/').length - right.split('/').length);
- for (const dir of dirs) {
- const dirPath = `${path === '/' ? '' : path}/${dir}/`;
- try {
- await fbqProxy('mkdir', dirPath);
- } catch {}
- }
-
- for (let index = 0; index < files.length; index += 1) {
- const { file, relativePath } = files[index];
- const formData = new FormData();
- formData.append('file', file);
- await fbqProxy('upload', path === '/' ? `/${relativePath}` : `${path}/${relativePath}`, { body: formData });
- setUploadProgress(Math.round(((index + 1) / files.length) * 100));
- }
- load(path);
- } catch {
- setError('Upload failed.');
- } finally {
- setUploading(false);
- setUploadProgress(null);
- }
- }
-
- async function collectEntry(entry, parent = '') {
- if (entry.isFile) {
- return [await new Promise((resolve, reject) => entry.file((file) => resolve({ file, relativePath: parent ? `${parent}/${file.name}` : file.name }), reject))];
- }
- if (!entry.isDirectory) return [];
- const nextParent = parent ? `${parent}/${entry.name}` : entry.name;
- const reader = entry.createReader();
- const children = [];
- while (true) {
- const batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject));
- if (!batch.length) break;
- children.push(...batch);
- }
- return (await Promise.all(children.map((child) => collectEntry(child, nextParent)))).flat();
- }
-
- async function handleDrop(event) {
- event.preventDefault();
- event.stopPropagation();
- dragDepthRef.current = 0;
- setDragActive(false);
-
- const entriesToRead = [...(event.dataTransfer?.items ?? [])]
- .filter((item) => item.kind === 'file')
- .map((item) => item.webkitGetAsEntry?.())
- .filter(Boolean);
-
- if (entriesToRead.length) {
- await uploadFiles((await Promise.all(entriesToRead.map((entry) => collectEntry(entry)))).flat());
- return;
- }
-
- await uploadFiles(
- [...(event.dataTransfer?.files ?? [])].map((file) => ({
- file,
- relativePath: file.webkitRelativePath || file.name,
- })),
- );
- }
-
- function handleDragEnter(event) {
- event.preventDefault();
- event.stopPropagation();
- dragDepthRef.current += 1;
- setDragActive(true);
- }
-
- function handleDragOver(event) {
- event.preventDefault();
- event.stopPropagation();
- event.dataTransfer.dropEffect = 'copy';
- }
-
- function handleDragLeave(event) {
- event.preventDefault();
- event.stopPropagation();
- dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
- if (dragDepthRef.current === 0) setDragActive(false);
- }
-
- function openCtxMenu(event, entry, childPath) {
- event.preventDefault();
- event.stopPropagation();
- setCtxMenu({ x: event.clientX, y: event.clientY, entry, childPath });
- }
-
- const crumbs = breadcrumbs();
-
- const sortedEntries = useMemo(() => {
- function valueOf(entry, column) {
- if (column === 'name') return (entry.name ?? entry.filename ?? '').toLowerCase();
- if (column === 'size') return Number(entry.size ?? -1);
- if (column === 'modified') return new Date(entry.modified ?? entry.lastModified ?? 0).getTime();
- return '';
- }
-
- return [...entries].sort((left, right) => {
- const leftIsDir = left.type === 'directory' || left.isDir;
- const rightIsDir = right.type === 'directory' || right.isDir;
- if (leftIsDir !== rightIsDir) return leftIsDir ? -1 : 1;
-
- let result = 0;
- const leftValue = valueOf(left, sortKey);
- const rightValue = valueOf(right, sortKey);
- if (typeof leftValue === 'string' && typeof rightValue === 'string') {
- result = leftValue.localeCompare(rightValue);
- } else if (leftValue === rightValue) {
- result = 0;
- } else {
- result = leftValue > rightValue ? 1 : -1;
- }
-
- if (result === 0) {
- const leftName = (left.name ?? left.filename ?? '').toLowerCase();
- const rightName = (right.name ?? right.filename ?? '').toLowerCase();
- result = leftName.localeCompare(rightName);
- }
-
- return sortDir === 'asc' ? result : -result;
- });
- }, [entries, sortDir, sortKey]);
-
- const fillerRowCount = useMemo(() => {
- if (!entries.length || !tableRowHeight || !tableViewportHeight) return 0;
- const availableHeight = tableViewportHeight - tableHeaderHeight;
- if (availableHeight <= 0) return 0;
- const visibleRowCount = Math.ceil(availableHeight / tableRowHeight) + 1;
- return Math.max(0, visibleRowCount - sortedEntries.length);
- }, [entries.length, sortedEntries.length, tableHeaderHeight, tableRowHeight, tableViewportHeight]);
-
- return (
-
-
-
-
-
Pinned
- {pins.length === 0 ? (
-
Right-click a folder to pin it.
- ) : (
-
- {pins.map((pinItem) => (
-
- navigate(pinItem.path)}
- onMouseEnter={() => setHoveredNavPath(pinItem.path)}
- onMouseLeave={() => setHoveredNavPath((prev) => (prev === pinItem.path ? null : prev))}
- style={{
- flex: 1,
- background: 'none',
- border: 'none',
- cursor: 'pointer',
- textAlign: 'left',
- padding: '4px 6px',
- borderRadius: 4,
- fontSize: 13,
- fontWeight: 500,
- color: path === pinItem.path || hoveredNavPath === pinItem.path ? 'var(--accent)' : 'var(--text-primary)',
- display: 'flex',
- alignItems: 'center',
- overflow: 'hidden',
- transition: 'color 140ms ease',
- }}
- >
- {pinItem.label}
-
- unpin(pinItem.path)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, color: 'var(--text-muted)', flexShrink: 0 }}>
-
-
-
-
-
-
- ))}
-
- )}
-
-
-
-
Navigation
-
- navigate(home)}
- onMouseEnter={() => setHoveredNavPath(home)}
- onMouseLeave={() => setHoveredNavPath((prev) => (prev === home ? null : prev))}
- style={{ width: '100%', background: 'none', border: 'none', cursor: 'pointer', textAlign: 'left', padding: '4px 6px', borderRadius: 4, fontSize: 13, fontWeight: 500, color: path === home || hoveredNavPath === home ? 'var(--accent)' : 'var(--text-primary)', display: 'flex', alignItems: 'center', marginBottom: 2, transition: 'color 140ms ease' }}
- >
- Home
-
- {renderNavTree(home, 0)}
-
-
-
-
-
-
- {selected.size > 0 && (
-
-
- {downloadingSelection ? 'Downloading...' : `Download (${selected.size})`}
-
-
- Delete ({selected.size})
-
-
- )}
-
-
0 ? 286 : 0 }}>
-
-
File Browser
-
- Drag and drop files or folders here.
-
-
- {crumbs.map((crumb, index) => (
-
- {index > 0 && / }
- navigate(crumb.path)}
- style={{
- background: 'none',
- border: 'none',
- cursor: index < crumbs.length - 1 ? 'pointer' : 'default',
- padding: 0,
- color: index === crumbs.length - 1 ? 'var(--accent)' : 'var(--text-primary)',
- fontSize: 'inherit',
- fontWeight: 'inherit',
- letterSpacing: 'inherit',
- textTransform: 'inherit',
- fontFamily: 'inherit',
- lineHeight: 'inherit',
- }}
- >
- {crumb.label}
-
-
- ))}
-
-
-
- {uploading &&
Uploading {uploadProgress}%
}
-
-
-
-
-
{
- if (e.target === e.currentTarget || e.target.tagName === 'TABLE' || e.target.closest('table')?.parentElement === e.currentTarget) {
- e.preventDefault();
- if (clipboard) setCtxMenu({ x: e.clientX, y: e.clientY, entry: null, childPath: null });
- }
- }}
- >
- {error &&
{error}
}
- {loading ? (
-
Loading...
- ) : entries.length === 0 ? (
-
This folder is empty.
- ) : (
-
- )}
-
-
- {(loading || uploading || downloadingSelection) && (
-
- )}
-
-
-
- {ctxMenu && (() => {
- const { x, y, entry, childPath } = ctxMenu;
- const isDir = entry ? (entry.type === 'directory' || entry.isDir) : false;
- const name = entry ? (entry.name ?? entry.filename ?? '') : '';
- const isPinned = entry ? pins.some((pinItem) => pinItem.path === childPath) : false;
- const clipboardItems = entry
- ? (selected.has(name)
- ? [...selected].map((n) => {
- const e = entries.find((en) => (en.name ?? en.filename) === n);
- return { name: n, path: e?.path ?? (path === '/' ? `/${n}` : `${path}/${n}`) };
- })
- : [{ name, path: childPath }])
- : [];
- const itemStyle = {
- display: 'flex',
- alignItems: 'center',
- gap: 8,
- padding: '7px 14px',
- fontSize: 13,
- cursor: 'pointer',
- color: 'var(--text-primary)',
- background: 'none',
- border: 'none',
- width: '100%',
- textAlign: 'left',
- borderRadius: 4,
- whiteSpace: 'nowrap',
- };
- const separatorStyle = { height: 1, background: 'var(--border)', margin: '4px 0' };
-
- return (
- event.stopPropagation()} style={{ ...popupMenuStyle, position: 'fixed', left: x, top: y, zIndex: 1000, padding: '4px 0', minWidth: 160 }}>
- {entry && isDir && (
-
{ event.currentTarget.style.background = 'var(--interactive-row-hover)'; }}
- onMouseLeave={(event) => { event.currentTarget.style.background = 'none'; }}
- onClick={() => {
- navigate(childPath);
- setCtxMenu(null);
- }}
- >
-
-
-
- Open
-
- )}
-
- {entry && !isDir && (
-
{ event.currentTarget.style.background = 'var(--interactive-row-hover)'; }}
- onMouseLeave={(event) => { event.currentTarget.style.background = 'none'; }}
- onClick={() => {
- handleDownload(entry);
- setCtxMenu(null);
- }}
- >
-
-
-
-
-
- Download
-
- )}
-
- {entry && isDir && (
- <>
-
-
{ event.currentTarget.style.background = 'var(--interactive-row-hover)'; }}
- onMouseLeave={(event) => { event.currentTarget.style.background = 'none'; }}
- onClick={() => {
- if (isPinned) unpin(childPath);
- else pin(name, childPath);
- setCtxMenu(null);
- }}
- >
-
-
-
- {isPinned ? 'Unpin' : 'Pin folder'}
-
- >
- )}
-
- {entry && (
- <>
-
-
{ event.currentTarget.style.background = 'var(--interactive-row-hover)'; }}
- onMouseLeave={(event) => { event.currentTarget.style.background = 'none'; }}
- onClick={() => {
- setClipboard({ items: clipboardItems, mode: 'copy' });
- setCtxMenu(null);
- }}
- >
-
-
-
-
- Copy{clipboardItems.length > 1 ? ` (${clipboardItems.length})` : ''}
-
-
{ event.currentTarget.style.background = 'var(--interactive-row-hover)'; }}
- onMouseLeave={(event) => { event.currentTarget.style.background = 'none'; }}
- onClick={() => {
- setClipboard({ items: clipboardItems, mode: 'move' });
- setCtxMenu(null);
- }}
- >
-
-
-
-
- Cut{clipboardItems.length > 1 ? ` (${clipboardItems.length})` : ''}
-
- >
- )}
-
- {clipboard && (
- <>
-
-
{ event.currentTarget.style.background = 'var(--interactive-row-hover)'; }}
- onMouseLeave={(event) => { event.currentTarget.style.background = 'none'; }}
- onClick={() => {
- handlePaste(path);
- setCtxMenu(null);
- }}
- >
-
-
-
-
- Paste here{clipboard.items.length > 1 ? ` (${clipboard.items.length})` : ''}
-
- >
- )}
-
- {accessScope.canManage && entry && (
- <>
-
-
{ event.currentTarget.style.background = 'rgba(255,80,80,0.08)'; }}
- onMouseLeave={(event) => { event.currentTarget.style.background = 'none'; }}
- onClick={() => {
- setCtxMenu(null);
- if (!window.confirm(`Delete "${name}"?`)) return;
- fbqProxy('delete', path, { headers: { 'X-Files': JSON.stringify([childPath]) } })
- .then(() => load(path))
- .catch(() => setError('Delete failed.'));
- }}
- >
-
-
-
-
-
-
-
- Delete
-
- >
- )}
-
- );
- })()}
-
- );
-}
diff --git a/src/pages/PayInvoice.jsx b/src/pages/PayInvoice.jsx
index 909a956..a5b0691 100644
--- a/src/pages/PayInvoice.jsx
+++ b/src/pages/PayInvoice.jsx
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { useParams, useSearchParams } from 'react-router-dom';
+import PageLoader from '../components/PageLoader';
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
@@ -74,15 +75,14 @@ export default function PayInvoice() {
return (
+ {loading ?
: null}
{/* Logo */}
- {loading ? (
-
Loading...
- ) : !invoice ? (
+ {!invoice ? (
Invoice not found
This payment link may be invalid or expired.
diff --git a/src/pages/Profile.jsx b/src/pages/Profile.jsx
index f8a7307..1fb8a37 100644
--- a/src/pages/Profile.jsx
+++ b/src/pages/Profile.jsx
@@ -689,7 +689,7 @@ export default function ProfilePage() {
{editError &&
{editError}
}
-
+
{savingProfile ? 'Saving...' : 'Save'}
diff --git a/src/pages/ProjectDetail.jsx b/src/pages/ProjectDetail.jsx
index 2971907..38d0799 100644
--- a/src/pages/ProjectDetail.jsx
+++ b/src/pages/ProjectDetail.jsx
@@ -1,34 +1,35 @@
-import { useState, useEffect, useCallback } from 'react';
+import { useState, useEffect } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import Layout from '../components/Layout';
import PageLoader from '../components/PageLoader';
+import ProfileAvatar from '../components/ProfileAvatar';
import StatusBadge from '../components/StatusBadge';
import SortTh from '../components/SortTh';
import { supabase } from '../lib/supabase';
import { useAuth } from '../context/AuthContext';
import { logActivity } from '../lib/activityLog';
-import { createTaskFolder, uploadFilesToRequestInfo } from '../lib/filebrowserFolders';
import { createInitialSubmissionForRequest, createTaskForRequest } from '../lib/requestSubmission';
+import { ensureRequestFolder, renameProjectFolder, uploadRequestFileToSurvey } from '../lib/folderSync';
import { sendEmail } from '../lib/email';
import RequestForm from '../components/RequestForm';
import { useSortable } from '../hooks/useSortable';
-import { serviceTypes } from '../data/mockData';
-import { addDaysToDateOnly, getTodayDateOnlyEST, fmtShortDate } from '../lib/dates';
-import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
-import { useRefetchOnFocus } from '../hooks/useRefetchOnFocus';
-import { useRealtimeSubscription } from '../hooks/useRealtimeSubscription';
+import { fmtShortDate } from '../lib/dates';
+import { popupOverlayStyle } from '../lib/popupStyles';
+import { useLiveRefresh } from '../hooks/useLiveRefresh';
+import { getTaskDerivedState } from '../lib/taskVersions';
+import {
+ TASK_TABLE_TH_STYLE,
+ TASK_TABLE_TD_BASE,
+ TASK_MODAL_TITLE_STYLE,
+ TASK_MODAL_SHELL_STYLE,
+} from '../lib/taskUi';
const CARD = { padding: '18px 21px', borderRadius: 8 };
const LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 14 };
const META_LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 4 };
const CARD_META_LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 2 };
-const MODAL_LBL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 6 };
-const TH_STYLE = { fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.6, color: 'var(--text-muted)', textAlign: 'left', padding: '0 0 12px 5px', border: 'none', background: 'transparent', verticalAlign: 'top' };
-const TD_BASE = { padding: '5px', border: 'none', background: 'transparent', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
const MODAL_IN = { fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' };
-const emptyJobForm = () => ({ title: '', serviceType: '', deadline: addDaysToDateOnly(getTodayDateOnlyEST(), 3), description: '', requestedBy: '' });
-
export default function ProjectDetailPage() {
const { id } = useParams();
const navigate = useNavigate();
@@ -38,28 +39,22 @@ export default function ProjectDetailPage() {
const isExternal = currentUser?.role === 'external';
const isTeam = currentUser?.role === 'team';
- const [refreshKey, setRefreshKey] = useState(0);
- const refresh = useCallback(() => setRefreshKey(k => k + 1), []);
- useRefetchOnFocus(refresh);
- useRealtimeSubscription(['tasks', 'projects', 'project_members', 'profiles'], refresh);
+ const { refreshKey } = useLiveRefresh(['tasks', 'projects', 'project_members', 'profiles']);
const [project, setProject] = useState(null);
const [company, setCompany] = useState(null);
const [tasks, setTasks] = useState([]);
const [members, setMembers] = useState([]);
const [externalProfiles, setExtProfs] = useState([]);
- const [companyUsers, setCompanyUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [submissions, setSubmissions] = useState([]);
+ const [deliveries, setDeliveries] = useState([]);
const [activeTab, setActiveTab] = useState('all');
const [editingName, setEditingName] = useState(false);
const [nameVal, setNameVal] = useState('');
const [savingName, setSavingName] = useState(false);
- const [showAddJob, setShowAddJob] = useState(false);
- const [jobForm, setJobForm] = useState(emptyJobForm());
- const [savingJob, setSavingJob] = useState(false);
const [showClientTask, setShowClientTask] = useState(false);
const [clientTaskKey, setClientTaskKey] = useState(0);
const [clientTaskSaving, setClientTaskSaving] = useState(false);
@@ -93,13 +88,20 @@ export default function ProjectDetailPage() {
if (taskIds.length > 0) {
const { data: subs } = await supabase.from('submissions').select('id, task_id, type, version_number, service_type, deadline, is_hot, submitted_at').in('task_id', taskIds).order('submitted_at', { ascending: false });
setSubmissions(subs || []);
+ if ((subs || []).length > 0) {
+ const { data: delRows } = await supabase.from('deliveries').select('id, submission_id, version_number, sent_at').in('submission_id', (subs || []).map(sub => sub.id));
+ setDeliveries(delRows || []);
+ } else {
+ setDeliveries([]);
+ }
+ } else {
+ setDeliveries([]);
}
const [{ data: users }, { data: pm }, { data: viaMembers }] = await Promise.all([
supabase.from('profiles').select('id, name, avatar_url, email').eq('company_id', p.company_id).eq('role', 'client'),
supabase.from('project_members').select('*, profile:profiles(id, name, avatar_url, email, role)').eq('project_id', id),
supabase.from('company_members').select('profile:profiles(id, name, avatar_url, email, role)').eq('company_id', p.company_id),
]);
- setCompanyUsers(users || []);
setMembers(pm || []);
const seen = new Set();
const merged = [];
@@ -128,8 +130,15 @@ export default function ProjectDetailPage() {
e.preventDefault();
if (!nameVal.trim()) return;
setSavingName(true);
- await supabase.from('projects').update({ name: nameVal.trim() }).eq('id', id);
- setProject(p => ({ ...p, name: nameVal.trim() }));
+ const oldName = project.name;
+ const newName = nameVal.trim();
+ await supabase.from('projects').update({ name: newName }).eq('id', id);
+ try {
+ await renameProjectFolder({ projectId: id, oldName, newName });
+ } catch (folderError) {
+ console.error('Project folder rename failed:', folderError);
+ }
+ setProject(p => ({ ...p, name: newName }));
setEditingName(false);
setSavingName(false);
};
@@ -151,23 +160,6 @@ export default function ProjectDetailPage() {
setTasks(prev => prev.filter(t => t.id !== taskId));
};
- const handleAddJob = async (e) => {
- e.preventDefault();
- setSavingJob(true);
- const requestor = requesterOptions.find(u => u.id === jobForm.requestedBy);
- if (!requestor) { setSavingJob(false); return; }
- const { data: task } = await supabase.from('tasks').insert({ project_id: id, title: jobForm.title.trim(), status: 'not_started', current_version: 0 }).select().single();
- if (task) {
- await supabase.from('submissions').insert({ task_id: task.id, version_number: 0, type: 'initial', service_type: jobForm.serviceType, deadline: jobForm.deadline || null, description: jobForm.description.trim() || null, submitted_by: requestor.id, submitted_by_name: requestor.name.replace(' (You)', '') });
- logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'task_created', taskId: task.id, taskTitle: task.title, projectId: id, projectName: project?.name });
- createTaskFolder(company?.name, project?.name, task.title).catch(() => {});
- setTasks(prev => [task, ...prev]);
- setJobForm(emptyJobForm());
- setShowAddJob(false);
- }
- setSavingJob(false);
- };
-
const handleAddMember = async () => {
setSavingMembers(true);
const currentIds = new Set(members.filter(m => m.profile?.role === 'external').map(m => m.profile_id));
@@ -188,18 +180,12 @@ export default function ProjectDetailPage() {
setSavingMembers(false);
};
- const handleRemoveMember = async (profileId) => {
- await supabase.from('project_members').delete().eq('project_id', id).eq('profile_id', profileId);
- setMembers(prev => prev.filter(m => m.profile_id !== profileId));
- };
-
const handleClientTask = async (formData, files) => {
if (clientTaskSaving) return;
setClientTaskSaving(true); setClientTaskError('');
try {
const { task } = await createTaskForRequest({ projectId: project.id, title: formData.title.trim(), requestKey: clientTaskRequestKey });
if (!task) throw new Error('Failed to create task.');
- createTaskFolder(company?.name, project.name, formData.title.trim()).catch(() => {});
const { submission } = await createInitialSubmissionForRequest({
taskId: task.id, requestKey: clientTaskRequestKey, isHot: formData.isHot,
serviceType: formData.serviceType, signFamily: formData.signFamily,
@@ -207,16 +193,32 @@ export default function ProjectDetailPage() {
deadline: formData.deadline, description: formData.description,
submittedBy: currentUser.id, submittedByName: currentUser.name,
});
+ try {
+ await ensureRequestFolder({ projectId: project.id, taskTitle: formData.title.trim() });
+ } catch (folderError) {
+ console.error('Request folder sync failed:', folderError);
+ }
if (submission && files.length > 0) {
for (const file of files) {
- const path = `${task.id}/${Date.now()}_${file.name}`;
+ const path = `${task.id}/Survey/${Date.now()}_${file.name}`;
const { data: up } = await supabase.storage.from('submissions').upload(path, file);
- if (up) await supabase.from('submission_files').insert({ submission_id: submission.id, name: file.name, storage_path: path, size: file.size });
+ if (up) {
+ await supabase.from('submission_files').insert({ submission_id: submission.id, name: file.name, storage_path: path, size: file.size });
+ try {
+ await uploadRequestFileToSurvey({
+ projectId: project.id,
+ taskTitle: formData.title.trim(),
+ storagePath: path,
+ fileName: file.name,
+ });
+ } catch (mirrorError) {
+ console.error('Survey folder mirror failed:', mirrorError);
+ }
+ }
}
- uploadFilesToRequestInfo(files, company?.name, project.name, formData.title.trim()).catch(() => {});
}
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'request_submitted', taskId: task.id, taskTitle: task.title, projectId: project.id, projectName: project.name }).catch(() => {});
- sendEmail('new_request', 'hello@fourgebranding.com', { clientName: currentUser.name, clientEmail: currentUser.email, company: company?.name || '', serviceType: formData.serviceType, projectName: project.name, deadline: formData.deadline, description: formData.description, taskId: task.id }).catch(() => {});
+ sendEmail('new_request', 'hello@fourgebranding.com', { clientName: currentUser.name, clientEmail: currentUser.email, company: company?.name || '', serviceType: formData.serviceType, projectName: project.name, projectId: project.id, deadline: formData.deadline, description: formData.description, taskId: task.id }).catch(() => {});
const { data: newTasks } = await supabase.from('tasks').select('*, assignee:profiles!assigned_to(avatar_url)').eq('project_id', id).order('submitted_at', { ascending: false });
setTasks(newTasks || []);
setShowClientTask(false); setClientTaskKey(k => k + 1); setClientTaskRequestKey(crypto.randomUUID());
@@ -227,19 +229,14 @@ export default function ProjectDetailPage() {
if (loading) return
;
if (!project) return
Project not found.
;
- const requesterOptions = [
- ...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)` }] : []),
- ...companyUsers.filter(u => u.id !== currentUser?.id),
- ];
-
const rows = tasks.map(task => {
- const sub = submissions.find(s => s.task_id === task.id && s.type === 'initial') || submissions.find(s => s.task_id === task.id);
+ const derived = getTaskDerivedState(task, submissions, deliveries);
return {
id: task.id, title: task.title, status: task.status,
- version: task.current_version || 0,
+ version: derived.currentVersion,
assignedName: task.assigned_name || null, assigneeAvatar: task.assignee?.avatar_url || null, assignedTo: task.assigned_to || null,
- serviceType: sub?.service_type || '—', deadline: sub?.deadline || null, isHot: sub?.is_hot || false,
- submittedAt: task.submitted_at || '',
+ serviceType: derived.serviceType, deadline: derived.deadline, isHot: derived.isHot,
+ submittedAt: derived.latestActivityAt ? new Date(derived.latestActivityAt).toISOString() : (task.submitted_at || ''),
};
});
@@ -258,7 +255,8 @@ export default function ProjectDetailPage() {
const doneStatuses = new Set(['client_approved', 'invoiced', 'paid']);
const taskTabs = [
{ id: 'all', label: 'All Tasks' },
- { id: 'not_started', label: 'To Do' },
+ { id: 'new_requests', label: 'New Requests' },
+ { id: 'revisions', label: 'Revisions' },
{ id: 'in_progress', label: 'In Progress' },
{ id: 'on_hold', label: 'On Hold' },
{ id: 'client_review', label: 'In Review' },
@@ -267,6 +265,8 @@ export default function ProjectDetailPage() {
];
const visibleRows = activeTab === 'all' ? sortedRows
: activeTab === 'completed' ? sortedRows.filter(r => doneStatuses.has(r.status))
+ : activeTab === 'new_requests' ? sortedRows.filter(r => r.status === 'not_started' && Number(r.version || 0) === 0)
+ : activeTab === 'revisions' ? sortedRows.filter(r => r.status === 'not_started' && Number(r.version || 0) >= 1)
: activeTab === 'members' ? []
: sortedRows.filter(r => r.status === activeTab);
@@ -345,12 +345,13 @@ export default function ProjectDetailPage() {
{(() => {
const tabCounts = {
- all: tasks.length,
- not_started: tasks.filter(t => t.status === 'not_started').length,
- in_progress: tasks.filter(t => t.status === 'in_progress').length,
- on_hold: tasks.filter(t => t.status === 'on_hold').length,
- client_review: tasks.filter(t => t.status === 'client_review').length,
- completed: tasks.filter(t => ['client_approved','invoiced','paid'].includes(t.status)).length,
+ all: rows.length,
+ new_requests: rows.filter(r => r.status === 'not_started' && Number(r.version || 0) === 0).length,
+ revisions: rows.filter(r => r.status === 'not_started' && Number(r.version || 0) >= 1).length,
+ in_progress: rows.filter(r => r.status === 'in_progress').length,
+ on_hold: rows.filter(r => r.status === 'on_hold').length,
+ client_review: rows.filter(r => r.status === 'client_review').length,
+ completed: rows.filter(r => ['client_approved','invoiced','paid'].includes(r.status)).length,
members: members.filter(m => m.profile?.role === 'external').length,
};
return (
@@ -375,7 +376,7 @@ export default function ProjectDetailPage() {
{activeTab !== 'members' && (
visibleRows.length === 0
- ?
No tasks yet.
+ ?
No tasks
:
@@ -389,44 +390,40 @@ export default function ProjectDetailPage() {
- R#
- Name
- Assigned
- Priority
- Task Type
- Deadline
- Status
+ R#
+ Name
+ Assigned
+ Priority
+ Task Type
+ Deadline
+ Status
{visibleRows.map(row => {
- const initials = row.assignedName ? row.assignedName.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase() : null;
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')}`}
-
+
{row.title || '—'}
-
+
{row.assignedName && row.assignedTo
?
- {row.assigneeAvatar
- ?
- : {initials}
- }
+
:
}
-
+
{row.isHot ? 'HOT' : 'NO'}
- {row.serviceType}
- {fmtShortDate(row.deadline, 'Not specified')}
-
+ {row.serviceType}
+ {fmtShortDate(row.deadline, 'Not specified')}
+
);
})}
@@ -439,7 +436,7 @@ export default function ProjectDetailPage() {
{(() => {
const subs = members.filter(m => m.profile?.role === 'external');
- if (subs.length === 0) return
No subcontractors assigned.
;
+ if (subs.length === 0) return
No subcontractors
;
const sorted = [...subs].sort((a, b) => {
if (subSortKey === 'projects') {
const av = subProjectCounts[a.profile_id] || 0;
@@ -461,29 +458,25 @@ export default function ProjectDetailPage() {
-
- Name
- Email
- Projects
-
+
+ Name
+ Email
+ Projects
+
{sorted.map(m => {
- const initials = (m.profile?.name || '?').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
const projCount = subProjectCounts[m.profile_id] || 0;
return (
-
- {m.profile?.avatar_url
- ?
- : {initials}
- }
+
+
- {m.profile?.name || '—'}
- {m.profile?.email || '—'}
- {projCount}
-
+ {m.profile?.name || '—'}
+ {m.profile?.email || '—'}
+ {projCount}
+
);
})}
@@ -502,17 +495,13 @@ export default function ProjectDetailPage() {
Main Contact
{companyClients.length === 0
- ?
No contacts found.
+ ?
No contacts
: (
{companyClients.map(person => {
- const initials = (person.name || '?').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
return (
- {person.avatar_url
- ?
- :
{initials}
- }
+
{person.name}
{person.email &&
{person.email}
}
@@ -530,7 +519,7 @@ export default function ProjectDetailPage() {
{(() => {
const SHOW = { task_started: { label: 'started', color: '#60a5fa', bg: 'rgba(96,165,250,0.15)', path: 'M6 4l14 8-14 8V4z' }, task_on_hold: { label: 'placed on hold', color: '#ef4444', bg: 'rgba(239,68,68,0.15)', path: 'M6 4h4v16H6zM14 4h4v16h-4z' }, task_approved: { label: 'approved', color: '#4ade80', bg: 'rgba(74,222,128,0.15)', path: 'M4 13l5 5L20 7' } };
const filtered = activityLog;
- if (filtered.length === 0) return
No activity yet.
;
+ if (filtered.length === 0) return
No activity
;
return (
{filtered.map(e => {
@@ -561,51 +550,10 @@ export default function ProjectDetailPage() {
- {/* Add Task modal — team only */}
- {showAddJob && isTeam && (
-
{ setShowAddJob(false); setJobForm(emptyJobForm()); }}>
-
e.stopPropagation()}>
-
Add Task — {project.name}
-
-
-
- )}
-
{showClientTask && isClient && (
{ setShowClientTask(false); setClientTaskKey(k => k + 1); setClientTaskError(''); }}>
-
e.stopPropagation()}>
-
New Task
+
e.stopPropagation()}>
+
New Task
{ setAddingMember(false); setSelectedExts(new Set()); }}>
- e.stopPropagation()}>
+
e.stopPropagation()}>
Manage Subcontractors
{project.name}
{externalProfiles.length === 0
- ?
No subcontractors found.
+ ?
No subcontractors
:
@@ -643,10 +591,10 @@ export default function ProjectDetailPage() {
-
- Name
- Email
- Assigned
+
+ Name
+ Email
+ Assigned
@@ -656,16 +604,10 @@ export default function ProjectDetailPage() {
return subSortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
}).map(p => {
const checked = selectedExts.has(p.id);
- const initials = (p.name || '?').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
return (
setSelectedExts(prev => { const s = new Set(prev); s.has(p.id) ? s.delete(p.id) : s.add(p.id); return s; })} style={{ cursor: 'pointer' }}>
- {p.avatar_url
- ?
- :
- {initials}
-
- }
+
{p.name}
{p.email || '—'}
@@ -681,7 +623,7 @@ export default function ProjectDetailPage() {
}
-
+
{savingMembers ? 'Saving…' : 'Save'}
{ setAddingMember(false); setSelectedExts(new Set()); }}>Cancel
diff --git a/src/pages/TaskDetail.jsx b/src/pages/TaskDetail.jsx
index 60e7bb4..56723dc 100644
--- a/src/pages/TaskDetail.jsx
+++ b/src/pages/TaskDetail.jsx
@@ -1,19 +1,20 @@
-import { useState, useEffect, useRef, useCallback } from 'react';
+import { useState, useEffect, useRef } from 'react';
import { useParams, Link } from 'react-router-dom';
import Layout from '../components/Layout';
import PageLoader from '../components/PageLoader';
-import FileBrowser from '../components/FileBrowser';
+import ProfileAvatar from '../components/ProfileAvatar';
import StatusBadge from '../components/StatusBadge';
import { useAuth } from '../context/AuthContext';
import { supabase } from '../lib/supabase';
import { logActivity } from '../lib/activityLog';
import JSZip from 'jszip';
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
-import { sendEmail } from '../lib/email';
-import { uploadFilesToRequestInfo, safeName } from '../lib/filebrowserFolders';
-import { useRefetchOnFocus } from '../hooks/useRefetchOnFocus';
-import { useRealtimeSubscription } from '../hooks/useRealtimeSubscription';
+import { useLiveRefresh } from '../hooks/useLiveRefresh';
import FileAttachment from '../components/FileAttachment';
+import { sendTaskStatusUpdate } from '../lib/taskNotifications';
+import { encodeRejectedNote, parseRejectedNote, isReviewShadowSubmission, REVIEW_SHADOW_PREFIX, getVisibleTaskSubmissions, getTaskDerivedState } from '../lib/taskVersions';
+import { getCurrentVersionForTask } from '../lib/taskDeadlines';
+import { getSubmissionDisplayName, mergeSubmissionDisplayNames } from '../lib/submissionDisplay';
const ACTION_LABEL = {
task_created: 'created this task', task_started: 'started this task', task_on_hold: 'placed task on hold',
@@ -63,8 +64,24 @@ const CARD = { padding: '18px 21px', borderRadius: 8 };
const LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 14 };
const META_LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 4 };
const CARD_META_LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 2 };
+const MODAL_ACTION_ROW = { display: 'flex', gap: 8, justifyContent: 'flex-end', paddingTop: 4, flexWrap: 'nowrap' };
+const MODAL_ACTION_BUTTON = { width: 132, justifyContent: 'center', flex: '0 0 132px' };
-const TABS = ['Overview', 'Revisions', 'Submissions', 'Comments', 'Folder'];
+const TABS = ['Overview', 'Revisions', 'Submissions', 'Comments'];
+function RejectedNoteBlock({ note }) {
+ if (!note?.body) return null;
+ const d = note.created_at ? new Date(note.created_at) : null;
+ const dateStr = d ? d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—';
+ return (
+
+
+
Rejected Notes
+
{dateStr}
+
+
{note.body}
+
+ );
+}
function TimelineCard({ task, activityLog, currentSubmission }) {
const status = task?.status;
@@ -135,19 +152,7 @@ function TimelineCard({ task, activityLog, currentSubmission }) {
}
function AssigneePortrait({ name, avatarUrl }) {
- if (avatarUrl) {
- return (
-
-
-
- );
- }
- const initials = (name || '?').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
- return (
-
- {initials}
-
- );
+ return
;
}
const fmtSize = (bytes) => {
@@ -194,13 +199,55 @@ function SubmissionFiles({ files, downloading, onDownloadAll }) {
);
}
+function SubmissionSigns({ signs = [], signCount = null }) {
+ const normalizedCount = Number(signCount || 0);
+ if ((!signs || signs.length === 0) && normalizedCount <= 0) return null;
+ return (
+
+
Signs
+ {signs && signs.length > 0 ? (
+
+ {signs
+ .slice()
+ .sort((a, b) => Number(a.sign_number || 0) - Number(b.sign_number || 0))
+ .map((sign) => (
+
+
+ Sign {sign.sign_number || '—'}
+ {sign.sign_name || 'Unnamed sign'}
+
+ {sign.sign_family ? (
+
+ {sign.sign_family}
+
+ ) : null}
+
+ ))}
+
+ ) : (
+
+ N/A
+
+ )}
+
+ );
+}
+
+function normalizeSubmissionSigns(submission) {
+ return (Array.isArray(submission?.signs) ? submission.signs : [])
+ .map((sign, index) => ({
+ ...sign,
+ sign_number: Number(sign?.sign_number || index + 1),
+ sign_name: String(sign?.sign_name || '').trim(),
+ sign_family: String(sign?.sign_family || '').trim(),
+ }))
+ .filter(sign => sign.sign_name || sign.sign_family || Number.isFinite(sign.sign_number));
+}
+
export default function TaskDetail() {
const { id } = useParams();
const { currentUser } = useAuth();
- const [refreshKey, setRefreshKey] = useState(0);
- const refresh = useCallback(() => setRefreshKey(k => k + 1), []);
- useRefetchOnFocus(refresh);
- useRealtimeSubscription(['tasks', 'submissions', 'activity_log', 'task_comments'], refresh);
+ const { refreshKey } = useLiveRefresh(['tasks', 'submissions', 'activity_log', 'task_comments']);
const [task, setTask] = useState(null);
const [submissions, setSubmissions] = useState([]);
const [activityLog, setActivityLog] = useState([]);
@@ -213,25 +260,55 @@ export default function TaskDetail() {
const [revisionFiles, setRevisionFiles] = useState([]);
const [revisionSaving, setRevisionSaving] = useState(false);
const revisionFileRef = useRef(null);
+ const [rejectModal, setRejectModal] = useState(false);
+ const [rejectNote, setRejectNote] = useState('');
+ const [rejectSaving, setRejectSaving] = useState(false);
const [amendModal, setAmendModal] = useState(false);
const [amendForm, setAmendForm] = useState({ description: '' });
const [amendFiles, setAmendFiles] = useState([]);
const [amendSaving, setAmendSaving] = useState(false);
- const [amendDragging, setAmendDragging] = useState(false);
- const amendDragCounter = useRef(0);
- const amendFileRef = useRef(null);
const [reviewModal, setReviewModal] = useState(false);
const [reviewFiles, setReviewFiles] = useState([]);
const [reviewNotes, setReviewNotes] = useState('');
const [reviewSaving, setReviewSaving] = useState(false);
- const [reviewDragging, setReviewDragging] = useState(false);
- const reviewDragCounter = useRef(0);
- const reviewFileRef = useRef(null);
const [comments, setComments] = useState([]);
const [commentBody, setCommentBody] = useState('');
const [commentSaving, setCommentSaving] = useState(false);
const [deliveries, setDeliveries] = useState([]);
const [dlProgress, setDlProgress] = useState({ active: false, label: '', percent: 0 });
+ const canonicalTaskServiceType = getTaskDerivedState(task, submissions, deliveries).serviceType;
+
+ const fetchTaskSubmissions = async (taskId) => {
+ const { data: subRows } = await supabase
+ .from('submissions')
+ .select('id, type, version_number, request_key, submitted_by, submitted_by_name, submitted_at, service_type, deadline, description, is_hot, sign_family, sign_count, signs, files:submission_files(id, name, storage_path, size)')
+ .eq('task_id', taskId)
+ .order('submitted_at', { ascending: true });
+
+ const submitterIds = [...new Set((subRows || []).map((submission) => submission.submitted_by).filter(Boolean))];
+ if (submitterIds.length === 0) {
+ return (subRows || []).map(submissionRow => ({ ...submissionRow, signs: normalizeSubmissionSigns(submissionRow) }));
+ }
+
+ const { data: submitterProfiles } = await supabase
+ .from('profiles')
+ .select('id, name')
+ .in('id', submitterIds);
+
+ return mergeSubmissionDisplayNames(
+ (subRows || []).map(submissionRow => ({ ...submissionRow, signs: normalizeSubmissionSigns(submissionRow) })),
+ submitterProfiles || []
+ );
+ };
+
+ const reloadTaskRow = async () => {
+ const { data } = await supabase
+ .from('tasks')
+ .select('*, project:projects(id, name, company:companies(id, name)), assignee:profiles!assigned_to(id, name, avatar_url)')
+ .eq('id', id)
+ .single();
+ if (data) setTask(data);
+ };
useEffect(() => {
Promise.all([
@@ -240,11 +317,7 @@ export default function TaskDetail() {
.select('*, project:projects(id, name, company:companies(id, name)), assignee:profiles!assigned_to(id, name, avatar_url)')
.eq('id', id)
.single(),
- supabase
- .from('submissions')
- .select('id, type, version_number, request_key, submitted_by, submitted_by_name, submitted_at, service_type, deadline, description, is_hot, sign_family, sign_count, files:submission_files(id, name, storage_path, size)')
- .eq('task_id', id)
- .order('submitted_at', { ascending: true }),
+ fetchTaskSubmissions(id),
supabase
.from('activity_log')
.select('id, created_at, actor_id, actor_name, action, task_id, task_title')
@@ -256,7 +329,7 @@ export default function TaskDetail() {
.select('id, author_id, author_name, body, created_at')
.eq('task_id', id)
.order('created_at', { ascending: true }),
- ]).then(async ([{ data: taskData }, { data: subRows }, { data: actData }, { data: commentData }]) => {
+ ]).then(async ([{ data: taskData }, subRows, { data: actData }, { data: commentData }]) => {
setTask(taskData);
setSubmissions(subRows || []);
setActivityLog(actData || []);
@@ -277,76 +350,225 @@ export default function TaskDetail() {
if (loading) return
;
if (!task) return
Task not found.
;
- const submission = submissions.find(s => s.type === 'initial') || submissions[0] || null;
- const amendments = submissions.filter(s => s.type === 'amendment');
- const revisions = submissions.filter(s => s.type === 'revision').sort((a, b) => a.version_number - b.version_number);
+ const visibleSubmissions = getVisibleTaskSubmissions(submissions);
+ const submission = visibleSubmissions.find(s => s.type === 'initial') || visibleSubmissions[0] || null;
+ const rejectedNoteSubmissions = visibleSubmissions
+ .filter(s => s.type === 'amendment' && parseRejectedNote(s.description))
+ .map(s => ({ ...s, rejected: parseRejectedNote(s.description) }));
+ const amendments = visibleSubmissions.filter(s => s.type === 'amendment' && !parseRejectedNote(s.description));
+ const revisions = visibleSubmissions.filter(s => s.type === 'revision').sort((a, b) => a.version_number - b.version_number);
const reviewSubmissions = deliveries;
+ const commentRows = comments;
+ const rejectedNoteByVersion = new Map(
+ rejectedNoteSubmissions.map(note => [note.rejected.versionNumber, { ...note, body: note.rejected.body, created_at: note.submitted_at }])
+ );
+ const displayCurrentVersion = getCurrentVersionForTask(task, submissions);
const assignee = task.assignee;
const assigneeName = assignee?.name || task.assigned_name || null;
const assigneeAvatar = assignee?.avatar_url || null;
const project = task.project;
const company = project?.company;
- const requester = submission?.submitted_by_name || null;
+ const requester = getSubmissionDisplayName(submission) || null;
const role = currentUser?.role;
const isClient = role === 'client';
const isTeamOrSub = role === 'team' || role === 'external';
const status = task.status;
+ const notificationBase = {
+ taskId: id,
+ taskTitle: task?.title || '',
+ projectId: project?.id || '',
+ projectName: project?.name || '',
+ companyId: company?.id || '',
+ companyName: company?.name || '',
+ assignedProfileId: task?.assigned_to || currentUser?.id || '',
+ };
+
+ const actionBusy = statusSaving || reviewSaving || revisionSaving || rejectSaving || amendSaving;
const log = (action) => logActivity({ actorId: currentUser.id, actorName: currentUser.name, action, taskId: id, taskTitle: task?.title, projectId: task?.project?.id, projectName: task?.project?.name });
const updateStatus = async (newStatus, extra = {}, action = null) => {
setStatusSaving(true);
- await supabase.from('tasks').update({ status: newStatus, ...extra }).eq('id', id);
+ const prevTask = task;
setTask(t => ({ ...t, status: newStatus, ...extra }));
- if (action) {
- await log(action);
- const { data } = await supabase.from('activity_log').select('id, created_at, actor_id, actor_name, action').eq('task_id', id).order('created_at', { ascending: false }).limit(20);
- setActivityLog(data || []);
+ try {
+ const { error: taskUpdateError } = await supabase.from('tasks').update({ status: newStatus, ...extra }).eq('id', id);
+ if (taskUpdateError) throw taskUpdateError;
+ await reloadTaskRow();
+ if (action) {
+ await log(action);
+ const { data } = await supabase.from('activity_log').select('id, created_at, actor_id, actor_name, action').eq('task_id', id).order('created_at', { ascending: false }).limit(20);
+ setActivityLog(data || []);
+ }
+ } catch (err) {
+ setTask(prevTask);
+ console.error('Status update failed:', err);
+ window.alert(err?.message || 'Failed to update status.');
+ throw err;
+ } finally {
+ setStatusSaving(false);
}
- setStatusSaving(false);
};
const handleStart = () => updateStatus('in_progress', { assigned_to: currentUser.id, assigned_name: currentUser.name }, 'task_started');
- const handleOnHold = () => updateStatus('on_hold', {}, 'task_on_hold');
+ const handleOnHold = async () => {
+ await updateStatus('on_hold', {}, 'task_on_hold');
+ sendTaskStatusUpdate({
+ ...notificationBase,
+ statusLabel: 'On Hold',
+ headline: 'Task On Hold',
+ subject: `Task On Hold: ${task?.title || 'Task'}`,
+ message: `${currentUser.name} placed ${task?.title || 'this task'} on hold${project?.name ? ` for ${project.name}` : ''}.`,
+ includeTeam: true,
+ includeAssigned: true,
+ includeClient: true,
+ }).catch(() => {});
+ };
const handleResume = () => updateStatus('in_progress', {}, 'task_resumed');
const handleSendToReview = () => { setReviewFiles([]); setReviewNotes(''); setReviewModal(true); };
const handleRemoveFromReview = () => updateStatus('in_progress', {}, 'task_resumed');
const handleConfirmReview = async () => {
setReviewSaving(true);
try {
- const briefId = submissions.find(s => s.type === 'initial')?.id || submissions[0]?.id;
- const { data: newDel } = await supabase.from('deliveries').insert({ submission_id: briefId, sent_by: currentUser.name, message: reviewNotes.trim() || '', version_number: task.current_version || 0 }).select().single();
+ const targetVersion = task.current_version || 0;
+ const versionSubmissions = submissions.filter(s => (s.version_number || 0) === targetVersion && s.type !== 'amendment');
+ const usedSubmissionIds = new Set(
+ deliveries
+ .filter(d => (d.version_number || 0) === targetVersion)
+ .filter(d => versionSubmissions.some(s => s.id === d.submission_id))
+ .map(d => d.submission_id)
+ );
+ let targetSubmission = versionSubmissions.find(s => !usedSubmissionIds.has(s.id)) || null;
+ if (!targetSubmission) {
+ const { data: createdSubmission, error: createdSubmissionError } = await supabase
+ .from('submissions')
+ .insert({
+ task_id: id,
+ version_number: targetVersion,
+ type: targetVersion === 0 ? 'initial' : 'revision',
+ revision_type: targetVersion > 0 ? 'client_revision' : null,
+ service_type: canonicalTaskServiceType !== '—' ? canonicalTaskServiceType : null,
+ description: `${REVIEW_SHADOW_PREFIX}${targetVersion}`,
+ submitted_by: currentUser.id,
+ submitted_by_name: currentUser.name,
+ })
+ .select('id, type, version_number, request_key, submitted_by, submitted_by_name, submitted_at, service_type, deadline, description, is_hot, sign_family, sign_count, signs')
+ .single();
+ if (createdSubmissionError) throw createdSubmissionError;
+ targetSubmission = createdSubmission || null;
+ }
+ if (!targetSubmission?.id) throw new Error('No submission found for current version.');
+ const { data: newDel, error: deliveryError } = await supabase
+ .from('deliveries')
+ .insert({ submission_id: targetSubmission.id, sent_by: currentUser.name, message: reviewNotes.trim() || '', version_number: targetVersion })
+ .select()
+ .single();
+ if (deliveryError) throw deliveryError;
+ if (!newDel?.id) throw new Error('Failed to create review submission.');
if (newDel && reviewFiles.length > 0) {
const uploaded = [];
for (const file of reviewFiles) {
const path = `${id}/${Date.now()}_${file.name}`;
- const { data: up } = await supabase.storage.from('deliveries').upload(path, file);
+ const { data: up, error: uploadError } = await supabase.storage.from('deliveries').upload(path, file);
+ if (uploadError) throw uploadError;
if (up) uploaded.push({ name: file.name, storage_path: path, size: file.size, delivery_id: newDel.id });
}
- if (uploaded.length > 0) await supabase.from('delivery_files').insert(uploaded);
+ if (uploaded.length > 0) {
+ const { error: deliveryFilesError } = await supabase.from('delivery_files').insert(uploaded);
+ if (deliveryFilesError) throw deliveryFilesError;
+ }
}
- const subIds = submissions.map(s => s.id);
+ const refreshedSubRows = await fetchTaskSubmissions(id);
+ const allSubRows = refreshedSubRows || [];
+ setSubmissions(allSubRows);
+ const subIds = allSubRows.map(s => s.id);
const { data: delRows } = await supabase.from('deliveries').select('*, files:delivery_files(id, name, storage_path, size)').in('submission_id', subIds).order('sent_at', { ascending: false });
setDeliveries(delRows || []);
await updateStatus('client_review', {}, 'task_submitted');
+ sendTaskStatusUpdate({
+ ...notificationBase,
+ statusLabel: 'In Review',
+ headline: 'Task Ready For Review',
+ subject: `In Review: ${task?.title || 'Task'}`,
+ message: `${currentUser.name} placed ${task?.title || 'this task'} in review${project?.name ? ` for ${project.name}` : ''}.${reviewNotes.trim() ? ` Notes: ${reviewNotes.trim()}` : ''}`,
+ includeTeam: true,
+ includeAssigned: true,
+ includeClient: true,
+ }).catch(() => {});
setReviewModal(false);
setReviewFiles([]);
setReviewNotes('');
+ } catch (err) {
+ console.error('Place in review failed:', err);
+ window.alert(err?.message || 'Failed to place task in review.');
} finally {
setReviewSaving(false);
}
};
- const handleClientApprove = () => updateStatus('client_approved', { completed_at: new Date().toISOString() }, 'task_approved');
- const handleClientReject = () => updateStatus('not_started', { current_version: (task.current_version || 0) + 1, assigned_to: null, assigned_name: null }, 'revision_requested');
+ const handleClientApprove = async () => {
+ await updateStatus('client_approved', { completed_at: new Date().toISOString() }, 'task_approved');
+ sendTaskStatusUpdate({
+ ...notificationBase,
+ statusLabel: 'Approved',
+ headline: 'Task Approved',
+ subject: `Approved: ${task?.title || 'Task'}`,
+ message: `${currentUser.name} approved ${task?.title || 'this task'}${project?.name ? ` for ${project.name}` : ''}.`,
+ includeTeam: true,
+ includeAssigned: true,
+ includeClient: true,
+ }).catch(() => {});
+ };
+ const handleClientReject = () => { setRejectNote(''); setRejectModal(true); };
+ const handleSubmitReject = async () => {
+ if (!rejectNote.trim()) return;
+ setRejectSaving(true);
+ try {
+ const rejectedVersion = task.current_version || 0;
+ const { data: rejectedNoteSubmission } = await supabase
+ .from('submissions')
+ .insert({
+ task_id: id,
+ version_number: rejectedVersion,
+ type: 'amendment',
+ service_type: canonicalTaskServiceType !== '—' ? canonicalTaskServiceType : null,
+ description: encodeRejectedNote(rejectedVersion, rejectNote.trim()),
+ submitted_by: currentUser.id,
+ submitted_by_name: currentUser.name,
+ })
+ .select()
+ .single();
+ if (rejectedNoteSubmission) setSubmissions(prev => [...prev, rejectedNoteSubmission]);
+ await updateStatus('in_progress', {}, 'revision_requested');
+ sendTaskStatusUpdate({
+ ...notificationBase,
+ statusLabel: 'Rejected',
+ headline: 'Task Rejected',
+ subject: `Rejected: ${task?.title || 'Task'}`,
+ message: `${currentUser.name} rejected ${task?.title || 'this task'}${project?.name ? ` for ${project.name}` : ''}. Notes: ${rejectNote.trim()}`,
+ includeTeam: true,
+ includeAssigned: true,
+ includeClient: true,
+ }).catch(() => {});
+ setRejectModal(false);
+ setRejectNote('');
+ } finally {
+ setRejectSaving(false);
+ }
+ };
const handleRelease = () => updateStatus('not_started', { assigned_to: null, assigned_name: null }, 'task_released');
const handleSubmitRevision = async () => {
setRevisionSaving(true);
const baseline = Math.max(task.current_version || 0, ...submissions.map(s => s.version_number || 0));
const newVersion = baseline + 1;
- await supabase.from('tasks').update({ status: 'not_started', current_version: newVersion, assigned_to: null, assigned_name: null }).eq('id', id);
- const { data: newSub } = await supabase.from('submissions').insert({ task_id: id, version_number: newVersion, type: 'revision', revision_type: 'client_revision', service_type: task.title, deadline: revisionForm.deadline || null, description: revisionForm.description, submitted_by: currentUser.id, submitted_by_name: currentUser.name }).select().single();
+ await supabase.from('tasks').update({
+ status: 'not_started',
+ current_version: newVersion,
+ assigned_to: task?.assigned_to || null,
+ assigned_name: task?.assigned_name || task?.assignee?.name || null,
+ }).eq('id', id);
+ const { data: newSub } = await supabase.from('submissions').insert({ task_id: id, version_number: newVersion, type: 'revision', revision_type: 'client_revision', service_type: canonicalTaskServiceType !== '—' ? canonicalTaskServiceType : null, deadline: revisionForm.deadline || null, description: revisionForm.description, submitted_by: currentUser.id, submitted_by_name: currentUser.name }).select().single();
if (newSub && revisionFiles.length > 0) {
const uploadedFiles = [];
for (const file of revisionFiles) {
@@ -355,18 +577,27 @@ export default function TaskDetail() {
if (uploaded) uploadedFiles.push({ name: file.name, storage_path: path, size: file.size });
}
if (uploadedFiles.length > 0) await supabase.from('submission_files').insert(uploadedFiles.map(f => ({ ...f, submission_id: newSub.id })));
- if (project?.name && task?.title) uploadFilesToRequestInfo(revisionFiles, company?.name, project.name, task.title, newVersion).catch(() => {});
}
await logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'revision_requested', taskId: id, taskTitle: task?.title, projectId: project?.id, projectName: project?.name });
- sendEmail('revision_submitted', ['hello@fourgebranding.com'], { clientName: currentUser.name, serviceType: task.title, projectName: project?.name, version: `R${String(newVersion).padStart(2, '0')}`, deadline: revisionForm.deadline, description: revisionForm.description, taskId: id }).catch(() => {});
- const [{ data: taskData }, { data: subRows }, { data: actData }] = await Promise.all([
+ const [{ data: taskData }, subRows, { data: actData }] = await Promise.all([
supabase.from('tasks').select('*, project:projects(id, name, company:companies(id, name)), assignee:profiles!assigned_to(id, name, avatar_url)').eq('id', id).single(),
- supabase.from('submissions').select('id, type, version_number, submitted_by, submitted_by_name, submitted_at, service_type, deadline, description, is_hot, sign_family, sign_count, files:submission_files(id, name, storage_path, size)').eq('task_id', id).order('submitted_at', { ascending: true }),
+ fetchTaskSubmissions(id),
supabase.from('activity_log').select('id, created_at, actor_id, actor_name, action, task_id, task_title').eq('task_id', id).order('created_at', { ascending: false }).limit(20),
]);
setTask(taskData);
setSubmissions(subRows || []);
setActivityLog(actData || []);
+ sendTaskStatusUpdate({
+ ...notificationBase,
+ assignedProfileId: task?.assigned_to || '',
+ statusLabel: `Revision Request ${`R${String(newVersion).padStart(2, '0')}`}`,
+ headline: 'Revision Requested',
+ subject: `Revision Request: ${task?.title || 'Task'} ${`R${String(newVersion).padStart(2, '0')}`}`,
+ message: `${currentUser.name} requested a revision for ${task?.title || 'this task'}${project?.name ? ` on ${project.name}` : ''}. ${revisionForm.description}`,
+ includeTeam: true,
+ includeAssigned: true,
+ includeClient: true,
+ }).catch(() => {});
setRevisionModal(false);
setRevisionForm({ description: '', deadline: '' });
setRevisionFiles([]);
@@ -378,7 +609,7 @@ export default function TaskDetail() {
setAmendSaving(true);
setAmendModal(false);
const baseline = Math.max(task.current_version || 0, ...submissions.map(s => s.version_number || 0));
- const { data: newSub } = await supabase.from('submissions').insert({ task_id: id, version_number: baseline, type: 'amendment', service_type: task.title, description: amendForm.description, submitted_by: currentUser.id, submitted_by_name: currentUser.name }).select().single();
+ const { data: newSub } = await supabase.from('submissions').insert({ task_id: id, version_number: baseline, type: 'amendment', service_type: canonicalTaskServiceType !== '—' ? canonicalTaskServiceType : null, description: amendForm.description, submitted_by: currentUser.id, submitted_by_name: currentUser.name }).select().single();
if (newSub && amendFiles.length > 0) {
setDlProgress({ active: true, label: 'Uploading files…', percent: 0 });
const uploaded = [];
@@ -390,11 +621,20 @@ export default function TaskDetail() {
if (up) uploaded.push({ name: file.name, storage_path: path, size: file.size });
}
if (uploaded.length > 0) await supabase.from('submission_files').insert(uploaded.map(f => ({ ...f, submission_id: newSub.id })));
- if (project?.name && task?.title) uploadFilesToRequestInfo(amendFiles, company?.name, project.name, task.title, baseline).catch(() => {});
setDlProgress({ active: false, label: '', percent: 0 });
}
- const { data: subRows } = await supabase.from('submissions').select('id, type, version_number, submitted_by, submitted_by_name, submitted_at, service_type, deadline, description, is_hot, sign_family, sign_count, files:submission_files(id, name, storage_path, size)').eq('task_id', id).order('submitted_at', { ascending: true });
+ const subRows = await fetchTaskSubmissions(id);
setSubmissions(subRows || []);
+ sendTaskStatusUpdate({
+ ...notificationBase,
+ statusLabel: 'Request Amended',
+ headline: 'Request Amended',
+ subject: `Request Amended: ${task?.title || 'Task'}`,
+ message: `${currentUser.name} amended ${task?.title || 'this task'}${project?.name ? ` for ${project.name}` : ''}. Notes: ${amendForm.description.trim()}`,
+ includeTeam: true,
+ includeAssigned: true,
+ includeClient: true,
+ }).catch(() => {});
setAmendForm({ description: '' });
setAmendFiles([]);
setAmendSaving(false);
@@ -454,38 +694,38 @@ export default function TaskDetail() {
{task.title}
{isTeamOrSub && status === 'not_started' && (
- Start Task
+ Start Task
)}
{isTeamOrSub && status === 'in_progress' && task.assigned_to === currentUser?.id && (
<>
- Release
- Place on Hold
- Place in Review
+ Release
+ Place on Hold
+ Place in Review
>
)}
{isTeamOrSub && status === 'client_review' && task.assigned_to === currentUser?.id && (
- Remove from Review
+ Remove from Review
)}
{isTeamOrSub && status === 'on_hold' && task.assigned_to === currentUser?.id && (
<>
- Release
- Resume
+ Release
+ Resume
>
)}
{isClient && status === 'client_review' && (
<>
- Approve
- Reject
+ Approve
+ Reject
>
)}
{isClient && ['client_approved', 'invoiced', 'paid'].includes(status) && (
- setRevisionModal(true)}>Request Revision
+ setRevisionModal(true)}>Request Revision
)}
- {submission?.service_type && (
- {submission.service_type}
+ {canonicalTaskServiceType !== '—' && (
+ {canonicalTaskServiceType}
)}
@@ -501,7 +741,7 @@ export default function TaskDetail() {
Revision
-
{(task.current_version ?? 0) === 0 ? 'New' : String(task.current_version).padStart(2, '0')}
+
{displayCurrentVersion === 0 ? 'New' : String(displayCurrentVersion).padStart(2, '0')}
@@ -541,7 +781,7 @@ export default function TaskDetail() {
{TABS.map(tab => {
- const count = tab === 'Revisions' ? revisions.length : tab === 'Submissions' ? reviewSubmissions.length : tab === 'Comments' ? comments.length : 0;
+ const count = tab === 'Revisions' ? revisions.length : tab === 'Submissions' ? reviewSubmissions.length : tab === 'Comments' ? commentRows.length : 0;
return (
setActiveTab(tab)} className={`section-tab-btn${activeTab === tab ? ' is-active' : ''}`}>
{tab}{count > 0 && {count} }
@@ -549,8 +789,8 @@ export default function TaskDetail() {
);
})}
-
-
+
+
{activeTab === 'Overview' && (
@@ -573,6 +813,11 @@ export default function TaskDetail() {
setAmendModal(true)}>Amend Request
)}
+ {rejectedNoteByVersion.get(0) && (
+
+
+
+ )}
{submission?.description && (
@@ -586,6 +831,7 @@ export default function TaskDetail() {
{submission.sign_family}
)}
+
{amendments.length > 0 && (
@@ -609,7 +855,7 @@ export default function TaskDetail() {
)}
{activeTab === 'Revisions' && (
revisions.length === 0
- ?
No revisions yet.
+ ?
No revisions
:
{[...revisions].reverse().map((rev, i, arr) => {
const rNum = `R${String(rev.version_number).padStart(2, '0')}`;
@@ -621,7 +867,7 @@ export default function TaskDetail() {
Requested By
-
{rev.submitted_by_name || '—'}
+
{getSubmissionDisplayName(rev) || '—'}
Requested
@@ -640,6 +886,11 @@ export default function TaskDetail() {
setAmendModal(true)}>Amend
)}
+ {rejectedNoteByVersion.get(rev.version_number) && (
+
+
+
+ )}
{rev.description && (
@@ -653,7 +904,8 @@ export default function TaskDetail() {
{rev.sign_family}
)}
- {!rev.description && !rev.sign_family && (
+
+ {!rev.description && !rev.sign_family && !(rev.signs?.length > 0) && !(Number(rev.sign_count || 0) > 0) && (
No notes provided.
)}
downloadAllFiles(f, rNum)} />
@@ -664,7 +916,7 @@ export default function TaskDetail() {
)}
{activeTab === 'Submissions' && (
reviewSubmissions.length === 0
- ? No submissions yet.
+ ? No submissions
:
{reviewSubmissions.map((sub, i, arr) => {
const fmtDate = sub.sent_at
@@ -681,7 +933,7 @@ export default function TaskDetail() {
Date
{fmtDate}
-
+
Version
{`R${String(sub.version_number ?? 0).padStart(2, '0')}`}
@@ -713,8 +965,8 @@ export default function TaskDetail() {
Post
{comments.length === 0
- ? No comments yet.
- : comments.map((c, i) => {
+ ? No comments
+ : commentRows.map((c, i) => {
const d = new Date(c.created_at);
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 });
@@ -737,22 +989,6 @@ export default function TaskDetail() {
}
)}
- {activeTab === 'Folder' && company?.name && project?.name && (() => {
- const fbInitial = role === 'external'
- ? `/Projects/${safeName(project.name)}/${safeName(task.title)}`
- : role === 'client'
- ? `/${safeName(company.name)}/Projects/${safeName(project.name)}/${safeName(task.title)}`
- : `/Clients/${safeName(company.name)}/Projects/${safeName(project.name)}/${safeName(task.title)}`;
- const fbRoot = role === 'external'
- ? `/Projects/${safeName(project.name)}`
- : role === 'client'
- ? `/${safeName(company.name)}/Projects/${safeName(project.name)}`
- : `/Clients/${safeName(company.name)}/Projects/${safeName(project.name)}`;
- return
;
- })()}
- {activeTab === 'Folder' && (!company?.name || !project?.name) && (
-
No folder available — task needs a project and company.
- )}
@@ -776,7 +1012,7 @@ export default function TaskDetail() {
Activity
{activityLog.length === 0 ? (
-
No activity yet.
+
No activity
) : (
{activityLog.map(e => (
@@ -797,36 +1033,11 @@ export default function TaskDetail() {
setReviewNotes(e.target.value)} style={{ fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box', resize: 'vertical' }} />
-
Attachments (optional)
-
{ setReviewFiles(prev => [...prev, ...Array.from(e.target.files)]); e.target.value = ''; }} />
-
{ e.preventDefault(); reviewDragCounter.current++; setReviewDragging(true); }}
- onDragLeave={e => { e.preventDefault(); reviewDragCounter.current--; if (reviewDragCounter.current === 0) setReviewDragging(false); }}
- onDragOver={e => e.preventDefault()}
- onDrop={e => { e.preventDefault(); reviewDragCounter.current = 0; setReviewDragging(false); setReviewFiles(prev => [...prev, ...Array.from(e.dataTransfer.files)]); }}
- onClick={() => reviewFileRef.current?.click()}
- style={{ border: `2px dashed ${reviewDragging ? 'var(--accent)' : reviewFiles.length > 0 ? 'var(--accent)' : 'var(--border)'}`, borderRadius: 8, padding: '16px', textAlign: 'center', background: reviewDragging ? 'color-mix(in srgb, var(--accent) 8%, var(--bg))' : 'transparent', transition: 'all 160ms', cursor: 'pointer' }}
- >
-
{reviewDragging ? '📂' : '📎'}
-
- {reviewDragging ? 'Drop files here' : reviewFiles.length > 0 ? `${reviewFiles.length} file${reviewFiles.length !== 1 ? 's' : ''} attached — click or drag to add more` : 'Click or drag files here'}
-
-
Any file type accepted
-
- {reviewFiles.length > 0 && (
-
- {reviewFiles.map((f, i) => (
-
- {f.name}
- setReviewFiles(prev => prev.filter((_, j) => j !== i))}>Remove
-
- ))}
-
- )}
+
-
-
{reviewSaving ? 'Submitting…' : 'Place in Review'}
-
{ setReviewModal(false); setReviewFiles([]); setReviewNotes(''); }}>Cancel
+
+ {reviewSaving ? 'Submitting…' : 'Place in Review'}
+ { setReviewModal(false); setReviewFiles([]); setReviewNotes(''); }}>Cancel
@@ -858,9 +1069,9 @@ export default function TaskDetail() {
-
-
{ setRevisionModal(false); setRevisionForm({ description: '', deadline: '' }); setRevisionFiles([]); }}>Cancel
-
+
+ { setRevisionModal(false); setRevisionForm({ description: '', deadline: '' }); setRevisionFiles([]); }}>Cancel
+
{revisionSaving ? 'Submitting…' : 'Submit Revision'}
@@ -868,6 +1079,30 @@ export default function TaskDetail() {
)}
+ {rejectModal && (
+
{ if (!rejectSaving) { setRejectModal(false); setRejectNote(''); } }}>
+
e.stopPropagation()}>
+
Reject Task
+
+
Rejected Notes
+
setRejectNote(e.target.value)}
+ style={{ width: '100%', fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '8px 10px', resize: 'vertical', fontFamily: 'inherit', boxSizing: 'border-box' }}
+ />
+
+
+ { setRejectModal(false); setRejectNote(''); }}>Cancel
+
+ {rejectSaving ? 'Saving…' : 'Reject'}
+
+
+
+
+ )}
+
{amendModal && (
{ setAmendModal(false); setAmendForm({ description: '' }); setAmendFiles([]); }}>
e.stopPropagation()}>
@@ -878,35 +1113,11 @@ export default function TaskDetail() {
Attach Files
-
{ setAmendFiles(prev => [...prev, ...Array.from(e.target.files)]); e.target.value = ''; }} />
-
{ e.preventDefault(); amendDragCounter.current++; setAmendDragging(true); }}
- onDragLeave={e => { e.preventDefault(); amendDragCounter.current--; if (amendDragCounter.current === 0) setAmendDragging(false); }}
- onDragOver={e => e.preventDefault()}
- onDrop={e => { e.preventDefault(); amendDragCounter.current = 0; setAmendDragging(false); setAmendFiles(prev => [...prev, ...Array.from(e.dataTransfer.files)]); }}
- style={{ border: `2px dashed ${amendDragging ? 'var(--accent)' : amendFiles.length > 0 ? 'var(--accent)' : 'var(--border)'}`, borderRadius: 8, padding: '16px', textAlign: 'center', background: amendDragging ? 'color-mix(in srgb, var(--accent) 8%, var(--bg))' : 'transparent', transition: 'all 160ms', cursor: 'pointer' }}
- onClick={() => amendFileRef.current?.click()}
- >
-
{amendDragging ? '📂' : '📎'}
-
- {amendDragging ? 'Drop files here' : amendFiles.length > 0 ? `${amendFiles.length} file${amendFiles.length !== 1 ? 's' : ''} attached — click or drag to add more` : 'Click or drag files here'}
-
-
Any file type accepted
-
- {amendFiles.length > 0 && (
-
- {amendFiles.map((f, i) => (
-
- {f.name}
- setAmendFiles(prev => prev.filter((_, idx) => idx !== i))}>Remove
-
- ))}
-
- )}
+
-
-
{amendSaving ? 'Saving…' : 'Save'}
-
{ setAmendModal(false); setAmendForm({ description: '' }); setAmendFiles([]); }}>Cancel
+
+ {amendSaving ? 'Saving…' : 'Save'}
+ { setAmendModal(false); setAmendForm({ description: '' }); setAmendFiles([]); }}>Cancel
diff --git a/src/pages/Tasks.jsx b/src/pages/Tasks.jsx
index 605e2a9..77e6904 100644
--- a/src/pages/Tasks.jsx
+++ b/src/pages/Tasks.jsx
@@ -1,23 +1,36 @@
-import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
+import { useState, useEffect, useRef, useMemo } from 'react';
import { Link } from 'react-router-dom';
import Layout from '../components/Layout';
+import PageLoader from '../components/PageLoader';
+import ProfileAvatar from '../components/ProfileAvatar';
import StatusBadge from '../components/StatusBadge';
import RequestForm from '../components/RequestForm';
import { supabase } from '../lib/supabase';
import { useAuth } from '../context/AuthContext';
import { readPageCache, writePageCache } from '../lib/pageCache';
import { withTimeout } from '../lib/withTimeout';
-import { getCurrentVersionForTask, getDeadlineSourceSubmission } from '../lib/taskDeadlines';
+import { getTaskDerivedState } from '../lib/taskVersions';
import { logActivity } from '../lib/activityLog';
import { fmtShortDate } from '../lib/dates';
import { createInitialSubmissionForRequest, createTaskForRequest, findOrCreateProject } from '../lib/requestSubmission';
+import { ensureProjectFolder, ensureRequestFolder, uploadRequestFileToSurvey } from '../lib/folderSync';
import { sendEmail } from '../lib/email';
-import { uploadFilesToRequestInfo, createTaskFolder } from '../lib/filebrowserFolders';
import SortTh from '../components/SortTh';
import { useSortable } from '../hooks/useSortable';
-import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
-import { useRefetchOnFocus } from '../hooks/useRefetchOnFocus';
-import { useRealtimeSubscription } from '../hooks/useRealtimeSubscription';
+import { popupOverlayStyle } from '../lib/popupStyles';
+import { useLiveRefresh } from '../hooks/useLiveRefresh';
+import { resolveScopedWorkIds } from '../lib/workScope';
+import { mergeSubmissionDisplayNames } from '../lib/submissionDisplay';
+import {
+ TASK_TABLE_TH_STYLE,
+ TASK_TABLE_TD_BASE,
+ TASK_MODAL_TITLE_STYLE,
+ TASK_MODAL_LABEL_STYLE,
+ TASK_MODAL_INPUT_STYLE,
+ TASK_MODAL_BUTTON_STYLE,
+ TASK_MODAL_SHELL_STYLE,
+ PROJECT_MODAL_SHELL_STYLE,
+} from '../lib/taskUi';
const TASK_STAT_ICONS = {
total: '
',
@@ -28,19 +41,11 @@ const TASK_STAT_ICONS = {
done: '
',
};
-const TASK_TABLE_TH_STYLE = { fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.6, color: 'var(--text-muted)', textAlign: 'left', padding: '0 0 12px 5px', border: 'none', background: 'transparent', verticalAlign: 'top' };
-const TASK_TABLE_TD_BASE = { padding: '5px', border: 'none', background: 'transparent', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
-const TASK_MODAL_TITLE_STYLE = { fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' };
-const TASK_MODAL_LABEL_STYLE = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 6 };
-const TASK_MODAL_INPUT_STYLE = { fontSize: 13, padding: '0 5px', textAlign: 'left', lineHeight: 1 };
-const TASK_MODAL_BUTTON_STYLE = { lineHeight: 1, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' };
-const TASK_MODAL_SHELL_STYLE = { ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(720px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto' };
-const PROJECT_MODAL_SHELL_STYLE = { ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(434px, 100%)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto' };
const TASK_STATUS_SORT_RANK = { not_started: 0, in_progress: 1, on_hold: 2, client_review: 3, client_approved: 4, invoiced: 5, paid: 6 };
-function TaskStatCard({ label, value, iconBg, iconColor, iconPath }) {
+function TaskStatCard({ label, value, sub, iconBg, iconColor, iconPath }) {
return (
-
+
{label}
@@ -48,6 +53,7 @@ function TaskStatCard({ label, value, iconBg, iconColor, iconPath }) {
{value}
+
{sub}
);
}
@@ -58,14 +64,17 @@ function TasksStatsRow({ tasks = [] }) {
const onHold = tasks.filter(t => t?.status === 'on_hold').length;
const inReview = tasks.filter(t => t?.status === 'client_review').length;
const completed = tasks.filter(t => ['client_approved', 'invoiced', 'paid'].includes(t?.status)).length;
+ const total = tasks.length;
+ const open = Math.max(total - completed, 0);
+ const pct = (count) => total > 0 ? `${Math.round((count / total) * 100)}% of total` : '0% of total';
return (
-
-
-
-
-
-
+
+
+
+
+
+
);
}
@@ -80,7 +89,7 @@ function TasksPageShell({ statsTasks = [], projects = [], onAddProject = null, c
};
const completedStatuses = new Set(['completed', 'cancelled', 'archived', 'done']);
const projectTabs = [
- { id: 'all', label: 'All Projects' },
+ { id: 'all', label: 'All' },
{ id: 'active', label: 'Active' },
{ id: 'completed', label: 'Completed' },
];
@@ -89,6 +98,11 @@ function TasksPageShell({ statsTasks = [], projects = [], onAddProject = null, c
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;
@@ -119,7 +133,14 @@ function TasksPageShell({ statsTasks = [], projects = [], onAddProject = null, c
{projectTabs.map(t => (
-
setProjTab(t.id)} className={`section-tab-btn${projTab === t.id ? ' is-active' : ''}`}>{t.label}
+
setProjTab(t.id)} className={`section-tab-btn${projTab === t.id ? ' is-active' : ''}`}>
+ {t.label}
+ {projectTabCounts[t.id] > 0 && (
+
+ {projectTabCounts[t.id]}
+
+ )}
+
))}
{onAddProject && (
@@ -128,39 +149,41 @@ function TasksPageShell({ statsTasks = [], projects = [], onAddProject = null, c
)}
-
-
-
-
-
-
-
-
- Name
- Status
-
-
-
- {sortedProjects.length === 0 ? (
- No projects.
- ) : sortedProjects.map(p => (
-
-
- {p.name}
-
-
-
-
-
-
- {projectCompletionPct(p)}%
-
-
+ {sortedProjects.length === 0 ? (
+ No projects
+ ) : (
+
+
+
+
+
+
+
+
+ Name
+ Status
- ))}
-
-
-
+
+
+ {sortedProjects.map(p => (
+
+
+ {p.name}
+
+
+
+
+
+
+ {projectCompletionPct(p)}%
+
+
+
+ ))}
+
+
+
+ )}
@@ -174,10 +197,7 @@ export default function RequestsPage() {
const isExternal = currentUser?.role === 'external';
const isClient = currentUser?.role === 'client';
- const [refreshKey, setRefreshKey] = useState(0);
- const refresh = useCallback(() => setRefreshKey(k => k + 1), []);
- useRefetchOnFocus(refresh);
- useRealtimeSubscription(['tasks', 'projects', 'submissions'], refresh);
+ const { refreshKey } = useLiveRefresh(['tasks', 'projects', 'submissions']);
const teamCached = isTeam ? readPageCache('team_requests') : null;
const extCached = isExternal ? readPageCache(`ext-requests:${currentUser?.id}`, 3 * 60_000) : null;
@@ -185,6 +205,7 @@ export default function RequestsPage() {
const [projects, setProjects] = useState(() => teamCached?.projects || extCached?.projects || []);
const [tasks, setTasks] = useState(() => teamCached?.tasks || extCached?.tasks || []);
const [submissions, setSubmissions] = useState(() => teamCached?.submissions || extCached?.submissions || []);
+ const [deliveries, setDeliveries] = useState([]);
const [companies, setCompanies] = useState(() => teamCached?.companies || []);
const [loading, setLoading] = useState(() => {
if (isTeam) return !teamCached;
@@ -193,7 +214,7 @@ export default function RequestsPage() {
});
const [error, setError] = useState('');
const [loadError, setLoadError] = useState(false);
- const [activeTab, setActiveTab] = useState('not_started');
+ const [activeTab, setActiveTab] = useState('new_requests');
const [filterCompany, setFilterCompany] = useState('');
const [filterProject, setFilterProject] = useState('');
const { sortKey, sortDir, toggle, sort } = useSortable('status', 'asc');
@@ -238,37 +259,11 @@ export default function RequestsPage() {
try {
setError(''); setLoadError(false);
- let scopedProjectIds = null;
- let scopedTaskIds = null;
-
- if (isClient) {
- const clientCos = currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : []);
- const companyIds = clientCos.map(c => c.id).filter(Boolean);
- if (companyIds.length > 0) {
- const { data: projRows } = await withTimeout(
- supabase.from('projects').select('id, tasks(id)').in('company_id', companyIds), 10000, 'Client project scope'
- );
- scopedProjectIds = (projRows || []).map(p => p.id);
- scopedTaskIds = (projRows || []).flatMap(p => (p.tasks || []).map(t => t.id));
- } else {
- scopedProjectIds = []; scopedTaskIds = [];
- }
- }
-
- if (isExternal) {
- const { data: memberData } = await withTimeout(
- supabase.from('project_members').select('project_id').eq('profile_id', currentUser.id), 10000, 'External project scope'
- );
- scopedProjectIds = (memberData || []).map(r => r.project_id).filter(Boolean);
- if (scopedProjectIds.length > 0) {
- const { data: taskRows } = await withTimeout(
- supabase.from('tasks').select('id').in('project_id', scopedProjectIds), 10000, 'External task scope'
- );
- scopedTaskIds = (taskRows || []).map(r => r.id).filter(Boolean);
- } else {
- scopedTaskIds = [];
- }
- }
+ const { scopedProjectIds, scopedTaskIds } = await withTimeout(
+ resolveScopedWorkIds(currentUser, { isClient, isExternal }),
+ 10000,
+ 'Tasks scope'
+ );
const tasksQ = supabase.from('tasks')
.select('id, title, status, current_version, project_id, assigned_name, assigned_to, invoiced, completed_at, submitted_at, assignee:profiles!assigned_to(avatar_url)')
@@ -298,12 +293,38 @@ export default function RequestsPage() {
);
if (cancelled) return;
- setProjects(p || []); setTasks(t || []); setSubmissions(subs || []); setCompanies(co || []);
+ const allSubs = subs || [];
+ const submitterIds = [...new Set(allSubs.map((submission) => submission.submitted_by).filter(Boolean))];
+ let submitterProfiles = [];
+ if (submitterIds.length > 0) {
+ const { data: profileRows } = await withTimeout(
+ supabase.from('profiles').select('id, name').in('id', submitterIds),
+ 10000,
+ 'Tasks submitter profiles load'
+ );
+ submitterProfiles = profileRows || [];
+ }
+ const hydratedSubs = mergeSubmissionDisplayNames(allSubs, submitterProfiles);
+ let deliveryRows = [];
+ if (hydratedSubs.length > 0) {
+ const { data: delRows } = await withTimeout(
+ supabase.from('deliveries').select('id, submission_id, version_number, sent_at').in('submission_id', hydratedSubs.map(sub => sub.id)),
+ 10000,
+ 'Tasks deliveries load'
+ );
+ deliveryRows = delRows || [];
+ }
+
+ setProjects(p || []);
+ setTasks(t || []);
+ setSubmissions(hydratedSubs);
+ setDeliveries(deliveryRows);
+ setCompanies(co || []);
if (isTeam) {
- writePageCache('team_requests', { submissions: subs || [], tasks: t || [], projects: p || [], companies: co || [] });
+ writePageCache('team_requests', { submissions: hydratedSubs, tasks: t || [], projects: p || [], companies: co || [] });
} else if (isExternal) {
- writePageCache(`ext-requests:${currentUser.id}`, { projects: p || [], tasks: t || [], submissions: subs || [] });
+ writePageCache(`ext-requests:${currentUser.id}`, { projects: p || [], tasks: t || [], submissions: hydratedSubs });
}
} catch (err) {
console.error('Tasks page load failed:', err);
@@ -317,7 +338,7 @@ export default function RequestsPage() {
return () => { cancelled = true; };
}, [isTeam, isExternal, isClient, currentUser?.id, refreshKey]); // eslint-disable-line react-hooks/exhaustive-deps
- const handleAddRequest = async (formData, _files, existingProjects) => {
+ const handleAddRequest = async (formData, files, existingProjects) => {
if (addSaving) return;
setAddSaving(true); setAddError('');
try {
@@ -326,7 +347,6 @@ export default function RequestsPage() {
const { task } = await createTaskForRequest({ projectId: resolvedProject.id, title: formData.title.trim(), requestKey: addRequestKey });
if (!task) throw new Error('Failed to create task.');
const taskCompany = companies?.find(c => c.id === formData.companyId);
- createTaskFolder(taskCompany?.name, resolvedProject.name, formData.title.trim()).catch(() => {});
const { submission: sub } = await createInitialSubmissionForRequest({
taskId: task.id, requestKey: addRequestKey, isHot: formData.isHot,
serviceType: formData.serviceType, signFamily: formData.signFamily,
@@ -335,12 +355,67 @@ export default function RequestsPage() {
description: formData.description, submittedBy: formData.requestedBy, submittedByName: formData.requestedByName,
});
if (!sub) throw new Error('Failed to create submission.');
+ try {
+ await ensureRequestFolder({ projectId: resolvedProject.id, taskTitle: formData.title.trim() });
+ } catch (folderError) {
+ console.error('Request folder sync failed:', folderError);
+ }
+ if (files.length > 0) {
+ const taskCompany = companies?.find(c => c.id === formData.companyId);
+ setShowAddForm(false);
+ setUploadProgress({ active: true, label: 'Uploading files…', percent: 0 });
+ for (let fi = 0; fi < files.length; fi++) {
+ const file = files[fi];
+ setUploadProgress({ active: true, label: file.name, percent: Math.round((fi / files.length) * 100) });
+ const path = `${task.id}/Survey/${Date.now()}_${file.name}`;
+ const { data: uploaded, error: uploadError } = await supabase.storage.from('submissions').upload(path, file);
+ if (uploadError) {
+ await supabase.from('tasks').delete().eq('id', task.id);
+ setUploadProgress({ active: false, label: '', percent: 0 });
+ throw new Error(`Upload failed: ${uploadError.message}`);
+ }
+ if (uploaded) {
+ const { error: fileErr } = await supabase.from('submission_files').insert({ submission_id: sub.id, name: file.name, storage_path: path, size: file.size });
+ if (fileErr) {
+ await supabase.from('tasks').delete().eq('id', task.id);
+ setUploadProgress({ active: false, label: '', percent: 0 });
+ throw new Error(`File record failed: ${fileErr.message}`);
+ }
+ try {
+ await uploadRequestFileToSurvey({
+ projectId: resolvedProject.id,
+ taskTitle: formData.title.trim(),
+ storagePath: path,
+ fileName: file.name,
+ });
+ } catch (mirrorError) {
+ console.error('Survey folder mirror failed:', mirrorError);
+ }
+ }
+ }
+ setUploadProgress({ active: false, label: '', percent: 0 });
+ }
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'request_submitted', taskId: task.id, taskTitle: task.title, projectId: resolvedProject.id, projectName: resolvedProject.name }).catch(() => {});
+ sendEmail('new_request', 'hello@fourgebranding.com', {
+ clientName: formData.requestedByName || currentUser.name,
+ clientEmail: currentUser.email || 'hello@fourgebranding.com',
+ company: taskCompany?.name || '',
+ serviceType: formData.serviceType,
+ projectName: resolvedProject.name,
+ projectId: resolvedProject.id,
+ deadline: formData.deadline,
+ description: formData.description,
+ taskId: task.id,
+ }).catch(() => {});
const [{ data: newSubs }, { data: newTasks }] = await Promise.all([
supabase.from('submissions').select('id, task_id, submitted_at, submitted_by, submitted_by_name, is_hot, service_type, deadline, version_number, type').order('submitted_at', { ascending: false }),
supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_name, assigned_to, invoiced, completed_at, submitted_at, assignee:profiles!assigned_to(avatar_url)').order('submitted_at', { ascending: false }),
]);
- setSubmissions(newSubs || []); setTasks(newTasks || []);
+ const submitterIds = [...new Set((newSubs || []).map((submission) => submission.submitted_by).filter(Boolean))];
+ const { data: submitterProfiles } = submitterIds.length > 0
+ ? await supabase.from('profiles').select('id, name').in('id', submitterIds)
+ : { data: [] };
+ setSubmissions(mergeSubmissionDisplayNames(newSubs || [], submitterProfiles || [])); setTasks(newTasks || []);
setShowAddForm(false); setAddFormKey(k => k + 1); setAddRequestKey(crypto.randomUUID());
} catch (err) { setAddError(err.message); }
finally { setAddSaving(false); }
@@ -355,7 +430,6 @@ export default function RequestsPage() {
const resolvedProject = await findOrCreateProject(formData.companyId, formData.project.trim(), existingProjects);
const { task } = await createTaskForRequest({ projectId: resolvedProject.id, title: formData.title.trim(), requestKey: addRequestKey });
if (!task) throw new Error('Failed to create task.');
- createTaskFolder(selectedCompany?.name, resolvedProject.name, formData.title.trim()).catch(() => {});
const { submission } = await createInitialSubmissionForRequest({
taskId: task.id, requestKey: addRequestKey, isHot: formData.isHot,
serviceType: formData.serviceType, signFamily: formData.signFamily,
@@ -363,28 +437,42 @@ export default function RequestsPage() {
deadline: formData.deadline,
description: formData.description, submittedBy: currentUser.id, submittedByName: currentUser.name,
});
+ try {
+ await ensureRequestFolder({ projectId: resolvedProject.id, taskTitle: formData.title.trim() });
+ } catch (folderError) {
+ console.error('Request folder sync failed:', folderError);
+ }
if (submission && files.length > 0) {
setShowAddForm(false);
setUploadProgress({ active: true, label: 'Uploading files…', percent: 0 });
for (let fi = 0; fi < files.length; fi++) {
const file = files[fi];
setUploadProgress({ active: true, label: file.name, percent: Math.round((fi / files.length) * 100) });
- const path = `${task.id}/${Date.now()}_${file.name}`;
+ const path = `${task.id}/Survey/${Date.now()}_${file.name}`;
const { data: uploaded, error: uploadError } = await supabase.storage.from('submissions').upload(path, file);
if (uploadError) { await supabase.from('tasks').delete().eq('id', task.id); setUploadProgress({ active: false, label: '', percent: 0 }); throw new Error(`Upload failed: ${uploadError.message}`); }
if (uploaded) {
const { error: fileErr } = await supabase.from('submission_files').insert({ submission_id: submission.id, name: file.name, storage_path: path, size: file.size });
if (fileErr) { await supabase.from('tasks').delete().eq('id', task.id); setUploadProgress({ active: false, label: '', percent: 0 }); throw new Error(`File record failed: ${fileErr.message}`); }
+ try {
+ await uploadRequestFileToSurvey({
+ projectId: resolvedProject.id,
+ taskTitle: formData.title.trim(),
+ storagePath: path,
+ fileName: file.name,
+ });
+ } catch (mirrorError) {
+ console.error('Survey folder mirror failed:', mirrorError);
+ }
}
}
- uploadFilesToRequestInfo(files, selectedCompany?.name, resolvedProject.name, formData.title.trim()).catch(() => {});
setUploadProgress({ active: false, label: '', percent: 0 });
}
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'request_submitted', taskId: task.id, taskTitle: task.title, projectId: resolvedProject.id, projectName: resolvedProject.name }).catch(() => {});
sendEmail('new_request', 'hello@fourgebranding.com', {
clientName: currentUser.name, clientEmail: currentUser.email,
company: selectedCompany?.name || '', serviceType: formData.serviceType,
- projectName: formData.project, deadline: formData.deadline,
+ projectName: formData.project, projectId: resolvedProject.id, deadline: formData.deadline,
description: formData.description, taskId: task.id,
}).catch(() => {});
const refreshCompanyIds = clientCos.map(c => c.id).filter(Boolean);
@@ -399,7 +487,11 @@ export default function RequestsPage() {
.select('id, task_id, submitted_at, submitted_by, submitted_by_name, is_hot, service_type, deadline, version_number, type')
.in('task_id', refreshTaskIds.length > 0 ? refreshTaskIds : ['__none__'])
.order('submitted_at', { ascending: false });
- setProjects(refreshProjects || []); setTasks(newTasks || []); setSubmissions(newSubs || []);
+ const submitterIds = [...new Set((newSubs || []).map((submission) => submission.submitted_by).filter(Boolean))];
+ const { data: submitterProfiles } = submitterIds.length > 0
+ ? await supabase.from('profiles').select('id, name').in('id', submitterIds)
+ : { data: [] };
+ setProjects(refreshProjects || []); setTasks(newTasks || []); setSubmissions(mergeSubmissionDisplayNames(newSubs || [], submitterProfiles || []));
setShowAddForm(false); setAddFormKey(k => k + 1); setAddRequestKey(crypto.randomUUID());
} catch (err) { setAddError(err.message); }
finally { setAddSaving(false); }
@@ -417,6 +509,11 @@ export default function RequestsPage() {
.from('projects').insert({ name, company_id: addProjectForm.companyId, status: 'active' })
.select('id, name, status, company_id, company:companies(id, name)').single();
if (insertError) throw insertError;
+ try {
+ await ensureProjectFolder({ projectId: newProject.id });
+ } catch (folderError) {
+ console.error('Project folder sync failed:', folderError);
+ }
setProjects(prev => [...prev, newProject]);
setShowAddProjectForm(false); setAddProjectForm({ name: '', companyId: '' });
} catch (err) { setAddProjectError(err.message || 'Failed to create project.'); }
@@ -436,37 +533,29 @@ export default function RequestsPage() {
const allRows = useMemo(() => {
if (isClient) {
return tasks.map(task => {
- const sub = submissions.find(s => s.task_id === task.id && s.type === 'initial');
+ const derived = getTaskDerivedState(task, submissions, deliveries);
return {
id: task.id, title: task.title, status: task.status, projectId: task.project_id,
- serviceType: sub?.service_type || '—', deadline: sub?.deadline || null,
- version: task.current_version || 0, isHot: false,
+ 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,
- submittedAt: task.submitted_at ? new Date(task.submitted_at).getTime() : 0,
+ submittedAt: derived.latestActivityAt,
};
});
}
return tasks.map(task => {
- const taskSubs = submissions.filter(s => s.task_id === task.id);
- const deadlineSource = getDeadlineSourceSubmission(task, taskSubs);
- if (!deadlineSource) return null;
- const currentVersion = getCurrentVersionForTask(task, taskSubs);
- const latestGroup = taskSubs.filter(s => s.version_number === currentVersion);
- const serviceType = submissions.find(s => s.task_id === task.id && s.type === 'initial')?.service_type
- || submissions.find(s => s.task_id === task.id && s.service_type)?.service_type
- || deadlineSource.service_type || '—';
+ 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, deadline: deadlineSource.deadline,
- version: deadlineSource.version_number ?? 0,
- isHot: deadlineSource.is_hot || false,
+ serviceType: derived.serviceType, deadline: derived.deadline,
+ version: derived.currentVersion,
+ isHot: derived.isHot,
assignedName: task.assigned_name || null, assigneeAvatar: task.assignee?.avatar_url || null, assignedTo: task.assigned_to || null,
- submittedAt: latestGroup.length > 0
- ? Math.max(...latestGroup.map(s => new Date(s.submitted_at).getTime()))
- : (task.submitted_at ? new Date(task.submitted_at).getTime() : 0),
+ submittedAt: derived.latestActivityAt,
};
}).filter(Boolean);
- }, [tasks, submissions, isClient]); // eslint-disable-line react-hooks/exhaustive-deps
+ }, [tasks, submissions, deliveries, isClient]); // eslint-disable-line react-hooks/exhaustive-deps
const filteredRows = useMemo(() => {
return allRows
@@ -482,16 +571,29 @@ export default function RequestsPage() {
const doneStatuses = new Set(['client_approved', 'invoiced', 'paid']);
const tabs = [
- { id: 'all', label: 'All Tasks' },
- { id: 'not_started', label: 'To Do' },
+ { id: 'all', label: 'All' },
+ { id: 'new_requests', label: 'New Requests' },
+ { id: 'revisions', label: 'Revisions' },
{ id: 'in_progress', label: 'In Progress' },
{ id: 'on_hold', label: 'On Hold' },
{ id: 'client_review', label: 'In Review' },
{ id: 'completed', label: 'Completed' },
];
- const tabRows = activeTab === 'all' ? filteredRows
+ const tabCounts = {
+ all: filteredRows.length,
+ new_requests: filteredRows.filter(r => r.status === 'not_started' && Number(r.version || 0) === 0).length,
+ revisions: filteredRows.filter(r => r.status === 'not_started' && Number(r.version || 0) >= 1).length,
+ in_progress: filteredRows.filter(r => r.status === 'in_progress').length,
+ on_hold: filteredRows.filter(r => r.status === 'on_hold').length,
+ client_review: filteredRows.filter(r => r.status === 'client_review').length,
+ completed: filteredRows.filter(r => doneStatuses.has(r.status)).length,
+ };
+
+ const tabRows = activeTab === 'all' ? filteredRows
: activeTab === 'completed' ? filteredRows.filter(r => doneStatuses.has(r.status))
+ : activeTab === 'new_requests' ? filteredRows.filter(r => r.status === 'not_started' && Number(r.version || 0) === 0)
+ : activeTab === 'revisions' ? filteredRows.filter(r => r.status === 'not_started' && Number(r.version || 0) >= 1)
: filteredRows.filter(r => r.status === activeTab);
const sortedRows = sort(tabRows, (row, key) => {
@@ -517,7 +619,6 @@ export default function RequestsPage() {
const renderRow = (row) => {
const project = projects.find(p => p.id === row.projectId);
- const initials = row.assignedName ? row.assignedName.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase() : null;
return (
@@ -531,14 +632,11 @@ export default function RequestsPage() {
{(() => {
- const avatarStyle = { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, borderRadius: '50%', overflow: 'hidden', verticalAlign: 'middle', flexShrink: 0 };
if (row.assignedName && row.assignedTo) {
- const portrait = row.assigneeAvatar
- ?
- : {initials}
;
+ const portrait = ;
return {portrait};
}
- return ;
+ return ;
})()}
@@ -554,7 +652,7 @@ export default function RequestsPage() {
);
};
- if (loading) return Loading...
;
+ if (loading) return ;
if (loadError) return Failed to load. Check your connection and try again.
window.location.reload()}>Retry ;
const filteredStatTasks = filteredRows.map(r => tasks.find(t => t.id === r.id)).filter(Boolean);
@@ -590,7 +688,7 @@ export default function RequestsPage() {
setAddProjectForm(f => ({ ...f, name: e.target.value }))} required autoFocus style={TASK_MODAL_INPUT_STYLE} />
{addProjectError && {addProjectError}
}
-
+
{addProjectSaving ? 'Saving...' : 'Save'}
{ setShowAddProjectForm(false); setAddProjectForm({ name: '', companyId: '' }); setAddProjectError(''); }}>Cancel
@@ -627,6 +725,11 @@ export default function RequestsPage() {
{tabs.map(t => (
setActiveTab(t.id)} className={`section-tab-btn${activeTab === t.id ? ' is-active' : ''}`}>
{t.label}
+ {tabCounts[t.id] > 0 && (
+
+ {tabCounts[t.id]}
+
+ )}
))}
@@ -692,9 +795,9 @@ export default function RequestsPage() {
{/* Task table */}
{allRows.length === 0 ? (
-
No tasks yet.
+
No tasks
) : sortedRows.length === 0 ? (
-
No tasks.
+
No tasks
) : (
diff --git a/src/pages/client/ClientMyInvoices.jsx b/src/pages/client/ClientMyInvoices.jsx
index 6c34c17..7f6f6a5 100644
--- a/src/pages/client/ClientMyInvoices.jsx
+++ b/src/pages/client/ClientMyInvoices.jsx
@@ -1,169 +1,401 @@
-import { useState, useEffect, useMemo } from 'react';
+import { useState, useEffect, useMemo, useRef } from 'react';
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
+import { Link } from 'react-router-dom';
+import { useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout';
import LoadingButton from '../../components/LoadingButton';
import SortTh from '../../components/SortTh';
import StatusBadge from '../../components/StatusBadge';
import { supabase } from '../../lib/supabase';
-import { generateInvoicePDF } from '../../lib/invoice';
+import { generateInvoicePDF, generateReceiptPDF } from '../../lib/invoice';
import { useAuth } from '../../context/AuthContext';
import { useSortable } from '../../hooks/useSortable';
+import { popupOverlayStyle, popupSurfaceStyle } from '../../lib/popupStyles';
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
+const invoiceStatusLabel = (status) => {
+ if (status === 'sent') return 'Invoiced';
+ return status ? status.charAt(0).toUpperCase() + status.slice(1) : '—';
+};
+
+function ClientFinanceStatCard({ label, value, sub, iconBg, iconColor, iconPath }) {
+ return (
+
+
+
{value}
+ {sub ?
{sub}
: null}
+
+ );
+}
+
+function ClientFinanceTooltip({ active, payload, label, year }) {
+ if (!active || !payload?.length) return null;
+ return (
+
+
{label} {year}
+ {payload.map(p => (
+
{p.name}: ${Number(p.value).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
+ ))}
+
+ );
+}
+
+function ClientInvoiceModal({ invoice, onClose }) {
+ const navigate = useNavigate();
+ const [downloading, setDownloading] = useState('');
+ if (!invoice) return null;
+
+ const items = invoice.items || [];
+ const company = invoice.company || null;
+ const isOverdue = invoice.status !== 'paid' && invoice.due_date && new Date(invoice.due_date) < new Date();
+
+ const handleDownloadInvoice = async () => {
+ if (downloading) return;
+ setDownloading('invoice');
+ try {
+ await generateInvoicePDF(invoice, company, items);
+ } finally {
+ setDownloading('');
+ }
+ };
+
+ const handleDownloadReceipt = async () => {
+ if (downloading) return;
+ setDownloading('receipt');
+ try {
+ await generateReceiptPDF(invoice, company, items);
+ } finally {
+ setDownloading('');
+ }
+ };
+
+ const openPay = () => {
+ onClose?.();
+ navigate(`/pay/${encodeURIComponent(invoice.invoice_number)}`);
+ };
+
+ const sortedItems = [...items].sort((a, b) => {
+ const aOrder = Number(a.sort_order ?? 0);
+ const bOrder = Number(b.sort_order ?? 0);
+ return aOrder - bOrder;
+ });
+
+ return (
+
+
e.stopPropagation()}>
+
+
+
{invoice.invoice_number}
+
+ {company?.id ? (
+
+ {company.name}
+
+ ) : (
+ company?.name || invoice.bill_to || 'Invoice'
+ )}
+
+
+
+
+ {invoice.status === 'sent' && (
+ Pay Invoice
+ )}
+
+ Download Invoice
+
+ {invoice.status === 'paid' && (
+
+ Download Receipt
+
+ )}
+ Close
+
+
+
+
+
+
Bill To
+
{invoice.bill_to || company?.name || '—'}
+
+ {company?.id ? (
+
+ {company.name}
+
+ ) : (
+ company?.name || 'Fourge Branding Client Invoice'
+ )}
+
+
+
+
Invoice Details
+
+
Invoice Date {invoice.invoice_date ? new Date(invoice.invoice_date).toLocaleDateString() : '—'}
+
Due Date {invoice.due_date ? new Date(invoice.due_date).toLocaleDateString() : '—'}
+
+
+
Total ${Number(invoice.total || 0).toFixed(2)}
+ {invoice.paid_at &&
Paid On {new Date(invoice.paid_at).toLocaleDateString()}
}
+
+
+
+
+
+
Line Items
+
+
+
+
+
+
+
+
+
+
+
+ Type
+ Description
+ Qty
+ Unit Price
+ Total
+
+
+
+ {sortedItems.map(item => (
+
+
+
+ {item.submission_id ? 'Revision' : 'New'}
+
+
+ {item.description}
+ {item.quantity}
+ ${Number(item.unit_price || 0).toFixed(2)}
+ ${(Number(item.quantity || 0) * Number(item.unit_price || 0)).toFixed(2)}
+
+ ))}
+
+
+
+
+
+
Total
+
${Number(invoice.total || 0).toFixed(2)}
+
+
+
+
+ {invoice.notes ? (
+
+
Notes
+
{invoice.notes}
+
+ ) : null}
+
+
+ );
+}
export default function MyInvoices() {
const { currentUser } = useAuth();
- const companies = (currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : [])).slice().sort((a, b) => a.name.localeCompare(b.name));
- const [activeCompanyId, setActiveCompanyId] = useState(companies[0]?.id || null);
+ const companyIds = (currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : []))
+ .map(company => company?.id)
+ .filter(Boolean);
const [invoices, setInvoices] = useState([]);
const [loading, setLoading] = useState(true);
- const [generatingInvoiceId, setGeneratingInvoiceId] = useState('');
- const { sortKey, sortDir, toggle, sort } = useSortable('invoice_date');
+ const [activeInvoice, setActiveInvoice] = useState(null);
+ const [yearFilter, setYearFilter] = useState(String(new Date().getFullYear()));
+ const [companyFilter, setCompanyFilter] = useState('all');
+ const [filterMenuOpen, setFilterMenuOpen] = useState(false);
+ const filterMenuRef = useRef(null);
+ const { sortKey, sortDir, toggle, sort } = useSortable('invoice_date', 'desc');
useEffect(() => {
async function load() {
+ if (companyIds.length === 0) {
+ setInvoices([]);
+ setLoading(false);
+ return;
+ }
const { data } = await supabase
.from('invoices')
- .select('*, company:companies(name), items:invoice_items(*)')
+ .select('*, company:companies(id, name), items:invoice_items(*)')
+ .in('company_id', companyIds)
.order('created_at', { ascending: false });
setInvoices((data || []).filter(inv => inv.status !== 'draft'));
setLoading(false);
}
load();
- }, []);
+ }, [currentUser?.id]); // eslint-disable-line react-hooks/exhaustive-deps
- const handleDownload = async (invoice) => {
- if (generatingInvoiceId) return;
- setGeneratingInvoiceId(invoice.id);
- try {
- await generateInvoicePDF(invoice, invoice.company, invoice.items || []);
- } finally {
- setGeneratingInvoiceId('');
+ useEffect(() => {
+ if (!filterMenuOpen) return;
+ function onDocClick(e) {
+ if (filterMenuRef.current && !filterMenuRef.current.contains(e.target)) setFilterMenuOpen(false);
}
- };
-
- const visible = companies.length > 1 && activeCompanyId
- ? invoices.filter(inv => inv.company_id === activeCompanyId)
- : invoices;
-
- const outstanding = visible.filter(i => i.status === 'sent').reduce((s, i) => s + Number(i.total), 0);
- const paid = visible.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total), 0);
- const overdueCount = visible.filter(inv => inv.status !== 'paid' && new Date(inv.due_date) < new Date()).length;
+ document.addEventListener('mousedown', onDocClick);
+ return () => document.removeEventListener('mousedown', onDocClick);
+ }, [filterMenuOpen]);
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
- const chartYear = new Date().getFullYear();
+ 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] : []))
+ .filter(company => company?.id && invoices.some(inv => inv.company_id === company.id))
+ .sort((a, b) => (a.name || '').localeCompare(b.name || ''));
+ const chartYear = Number(yearFilter) || new Date().getFullYear();
+ const filteredInvoices = invoices.filter(inv => {
+ if (yearFilter !== 'all' && inv.invoice_date?.slice(0, 4) !== yearFilter) return false;
+ if (companyFilter !== 'all' && inv.company_id !== companyFilter) return false;
+ return true;
+ });
const chartData = useMemo(() => MONTHS.map((month, mi) => {
- const paidAmt = visible.filter(i => i.status === 'paid' && new Date(i.invoice_date).getFullYear() === chartYear && new Date(i.invoice_date).getMonth() === mi).reduce((s, i) => s + Number(i.total || 0), 0);
- const outAmt = visible.filter(i => i.status === 'sent' && new Date(i.invoice_date).getFullYear() === chartYear && new Date(i.invoice_date).getMonth() === mi).reduce((s, i) => s + Number(i.total || 0), 0);
+ const paidAmt = filteredInvoices.filter(i => i.status === 'paid' && new Date(i.invoice_date).getFullYear() === chartYear && new Date(i.invoice_date).getMonth() === mi).reduce((s, i) => s + Number(i.total || 0), 0);
+ const outAmt = filteredInvoices.filter(i => i.status === 'sent' && new Date(i.invoice_date).getFullYear() === chartYear && new Date(i.invoice_date).getMonth() === mi).reduce((s, i) => s + Number(i.total || 0), 0);
return { month, Paid: +paidAmt.toFixed(2), Outstanding: +outAmt.toFixed(2) };
- }), [visible, chartYear]);
- const hasChartData = chartData.some(d => d.Paid > 0 || d.Outstanding > 0);
+ }), [filteredInvoices, chartYear]);
- const sorted = sort(visible, (inv, key) => {
+ const outstanding = filteredInvoices.filter(i => i.status === 'sent').reduce((s, i) => s + Number(i.total || 0), 0);
+ const paid = filteredInvoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total || 0), 0);
+ const overdueCount = filteredInvoices.filter(inv => inv.status !== 'paid' && inv.due_date && new Date(inv.due_date) < new Date()).length;
+ const paidCount = filteredInvoices.filter(i => i.status === 'paid').length;
+
+ const sortedInvoices = sort(filteredInvoices, (inv, key) => {
if (key === 'invoice_date' || key === 'due_date') return new Date(inv[key] || 0).getTime();
if (key === 'total') return Number(inv.total || 0);
+ if (key === 'company') return inv.company?.name || '';
return inv[key] || '';
});
- const th = { sortKey, sortDir, onSort: toggle };
-
return (
-
-
-
-
Invoices
-
{visible.length} invoice{visible.length !== 1 ? 's' : ''}
+
+
+
+
+
+ Invoice Overview
+ {chartYear}
+
+
+
+
+
+
+
+
+
+ `$${v >= 1000 ? (v / 1000).toFixed(0) + 'k' : v}`} width={45} />
+ } />
+
+
+
+
+
+
+
+
+
'} />
+
'} />
+
'} />
+
'} />
+
+
+
+
+
+
setFilterMenuOpen(o => !o)} style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }} title="Filter invoices">
+
+
+ {filterMenuOpen && (
+
+
Year
+
{ setYearFilter('all'); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: yearFilter === 'all' ? 'rgba(245,165,35,0.08)' : 'transparent', color: yearFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Years
+ {invoiceYears.map(year => (
+
{ setYearFilter(year); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: yearFilter === year ? 'rgba(245,165,35,0.08)' : 'transparent', color: yearFilter === year ? 'var(--accent)' : 'var(--text-primary)' }}>{year}
+ ))}
+ {availableCompanyOptions.length > 1 && (
+ <>
+
+
Company
+
{ setCompanyFilter('all'); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: companyFilter === 'all' ? 'rgba(245,165,35,0.08)' : 'transparent', color: companyFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Companies
+ {availableCompanyOptions.map(company => (
+
{ setCompanyFilter(company.id); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: companyFilter === company.id ? 'rgba(245,165,35,0.08)' : 'transparent', color: companyFilter === company.id ? 'var(--accent)' : 'var(--text-primary)' }}>{company.name}
+ ))}
+ >
+ )}
+
+ )}
+
+
+
+
+
+ {filteredInvoices.length === 0 ? (
+
No invoices
+ ) : (
+
+
+
+
+
+
+
+
+
+
+
+
+ Invoice #
+ Company
+ Issued
+ Due
+ Status
+ Total
+
+
+
+ {sortedInvoices.map(inv => {
+ const isOverdue = inv.status !== 'paid' && inv.due_date && new Date(inv.due_date) < new Date();
+ return (
+
+
+ setActiveInvoice(inv)}>{inv.invoice_number}
+
+
+ {inv.company?.id ? (
+
+ {inv.company?.name || inv.bill_to || '—'}
+
+ ) : (
+ inv.company?.name || inv.bill_to || '—'
+ )}
+
+ {inv.invoice_date ? new Date(inv.invoice_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}
+ {inv.due_date ? new Date(inv.due_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}
+
+
+
+ ${Number(inv.total || 0).toFixed(2)}
+
+ );
+ })}
+
+
+
+ )}
+
-
- {hasChartData && (
-
-
-
-
-
-
-
-
-
- `$${v >= 1000 ? (v/1000).toFixed(0)+'k' : v}`} width={45} />
- [`$${Number(v).toLocaleString('en-US', { minimumFractionDigits: 2 })}`, undefined]} contentStyle={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, fontSize: 12 }} />
-
-
-
-
-
-
- )}
-
- {companies.length > 1 && (
-
- setActiveCompanyId(e.target.value)}>
- {companies.map(c => {c.name} )}
-
-
- )}
-
- {loading ? (
- Loading...
- ) : visible.length === 0 ? (
-
-
No invoices yet
-
Your invoices will appear here once they are sent.
-
- ) : (
-
-
-
-
-
- Invoice #
- Issued
- Due
- Status
- Total
-
-
-
-
- {sorted.map(inv => {
- const isOverdue = inv.status !== 'paid' && new Date(inv.due_date) < new Date();
- return (
-
- {inv.invoice_number}
- {new Date(inv.invoice_date).toLocaleDateString()}
-
- {inv.due_date ? new Date(inv.due_date).toLocaleDateString() : '—'}
- {isOverdue && Overdue }
-
-
-
-
- ${Number(inv.total).toFixed(2)}
-
- handleDownload(inv)}
- >
- Download PDF
-
-
-
- );
- })}
-
-
-
-
- )}
+ setActiveInvoice(null)} />
);
}
diff --git a/src/pages/external/ExternalMyInvoiceCreate.jsx b/src/pages/external/ExternalMyInvoiceCreate.jsx
index 09059c1..5978c7f 100644
--- a/src/pages/external/ExternalMyInvoiceCreate.jsx
+++ b/src/pages/external/ExternalMyInvoiceCreate.jsx
@@ -1,296 +1,10 @@
-import { useState, useEffect, useRef, useMemo } from 'react';
-import { useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout';
-import LoadingButton from '../../components/LoadingButton';
-import { supabase } from '../../lib/supabase';
-import { useAuth } from '../../context/AuthContext';
-import { sendEmail } from '../../lib/email';
-
-const INVOICE_TODAY = new Date().toISOString().split('T')[0];
-
-function newItem(description = '', unit_price = '', quantity = 1, taskId = null, isRevision = false) {
- return { id: crypto.randomUUID(), description, unit_price, quantity, task_id: taskId, is_revision: isRevision };
-}
-
-function genNumber(count) {
- const year = new Date().getFullYear();
- return `INVSUB-${year}-${String(count + 1).padStart(3, '0')}`;
-}
+import SubcontractorInvoiceForm from '../../components/SubcontractorInvoiceForm';
export default function MyInvoiceCreate() {
- const navigate = useNavigate();
- const { currentUser } = useAuth();
-
- const [invoiceNumber, setInvoiceNumber] = useState('');
- const [completedTasks, setCompletedTasks] = useState([]);
- const [loadingTasks, setLoadingTasks] = useState(true);
- const [items, setItems] = useState([newItem()]);
- const [notes, setNotes] = useState('');
- const [saving, setSaving] = useState(false);
- const [error, setError] = useState('');
- const dragItem = useRef(null);
-
- const rate = Number(currentUser?.brand_book_rate || 60);
-
- useEffect(() => {
- async function load() {
- const [{ data: tasks }, { data: existingInvoices }, { data: nextNum }] = await Promise.all([
- supabase
- .from('tasks')
- .select('id, title, current_version, project:projects(name)')
- .eq('status', 'client_approved')
- .eq('assigned_to', currentUser.id)
- .order('title'),
- supabase
- .from('subcontractor_invoices')
- .select('id, status, items:subcontractor_invoice_items(task_id)')
- .in('status', ['submitted', 'paid']),
- supabase.rpc('get_next_sub_invoice_number'),
- ]);
-
- setInvoiceNumber(nextNum || genNumber(0));
-
- const invoicedTaskIds = new Set(
- (existingInvoices || []).flatMap(inv => (inv.items || []).map(i => i.task_id).filter(Boolean))
- );
- setCompletedTasks((tasks || []).filter(t => !invoicedTaskIds.has(t.id)));
- setLoadingTasks(false);
- }
- load();
- }, []);
-
- const addedTaskIds = useMemo(() => new Set(items.map(i => i.task_id).filter(Boolean)), [items]);
-
- const addTask = (task) => {
- if (addedTaskIds.has(task.id)) return;
- const version = task.current_version || 0;
- const baseDesc = task.project?.name ? `${task.project.name} • ${task.title}` : task.title;
- const toAdd = [newItem(`${baseDesc} – R00`, rate, 1, task.id, false)];
- for (let v = 1; v <= version; v++) {
- toAdd.push(newItem(`${baseDesc} – R${String(v).padStart(2, '0')}`, 30, 1, task.id, true));
- }
- setItems(prev => {
- if (prev.length === 1 && !prev[0].description && !prev[0].unit_price) return toAdd;
- return [...prev, ...toAdd];
- });
- };
-
- const updateItem = (id, field, value) => {
- setItems(prev => prev.map(item => item.id === id ? { ...item, [field]: value } : item));
- };
-
- const removeItem = (id) => setItems(prev => prev.filter(item => item.id !== id));
-
- const handleDrop = (targetIndex) => {
- if (dragItem.current === null || dragItem.current === targetIndex) { dragItem.current = null; return; }
- setItems(prev => {
- const next = [...prev];
- const [moved] = next.splice(dragItem.current, 1);
- next.splice(targetIndex, 0, moved);
- return next;
- });
- dragItem.current = null;
- };
-
- const total = items.reduce((s, i) => s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0), 0);
-
- const handleSubmit = async () => {
- const valid = items.filter(i => String(i.description).trim());
- if (!valid.length) { setError('Add at least one line item.'); return; }
- setSaving(true);
- setError('');
- try {
- const { data: inv, error: invErr } = await supabase
- .from('subcontractor_invoices')
- .insert({
- profile_id: currentUser.id,
- invoice_number: invoiceNumber.trim(),
- status: 'submitted',
- notes: notes.trim(),
- submitted_at: new Date().toISOString(),
- })
- .select()
- .single();
- if (invErr) throw invErr;
-
- const { error: itemsErr } = await supabase.from('subcontractor_invoice_items').insert(
- valid.map((item, idx) => ({
- invoice_id: inv.id,
- task_id: item.task_id || null,
- description: String(item.description).trim(),
- quantity: Number(item.quantity) || 1,
- unit_price: Number(item.unit_price) || 0,
- sort_order: idx,
- }))
- );
- if (itemsErr) throw itemsErr;
-
- const total = valid.reduce((s, i) => s + (Number(i.unit_price) || 0) * (Number(i.quantity) || 1), 0);
- sendEmail('subcontractor_invoice_submitted', 'hello@fourgebranding.com', {
- subName: currentUser.name,
- invoiceNumber: inv.invoice_number,
- total: total.toFixed(2),
- invoiceId: inv.id,
- }).catch(err => console.error('Sub invoice notification failed:', err));
-
- navigate(`/my-invoices-sub/${inv.id}`);
- } catch (err) {
- setError(err.message);
- setSaving(false);
- }
- };
-
return (
- navigate('/my-invoices-sub')}>← Back to Invoices
-
-
-
-
New Invoice
-
- Invoice date: {new Date(INVOICE_TODAY).toLocaleDateString()} · {invoiceNumber}
-
-
-
-
- {error && {error}
}
-
-
-
-
Completed Tasks
- {completedTasks.length > 0 && !loadingTasks && (
-
completedTasks.forEach(task => { if (!addedTaskIds.has(task.id)) addTask(task); })}
- >
- + Add All
-
- )}
-
- {loadingTasks ? (
-
Loading...
- ) : completedTasks.length === 0 ? (
-
No completed tasks available to invoice.
- ) : (
-
- {completedTasks.map(task => {
- const alreadyAdded = addedTaskIds.has(task.id);
- return (
-
-
-
- {task.project?.name ? `${task.project.name} • ` : ''}{task.title}
-
-
- {(() => {
- const v = task.current_version || 0;
- const parts = [`R00 New Book $${rate.toFixed(2)}`];
- if (v > 0) parts.push(`+ ${v} Revision${v > 1 ? 's' : ''} @ $30ea`);
- return parts.join(' · ');
- })()}
-
-
-
!alreadyAdded && addTask(task)}
- disabled={alreadyAdded}
- >
- {alreadyAdded ? 'Added' : '+ Add'}
-
-
- );
- })}
-
- )}
-
-
-
-
-
-
- {['', 'Type', 'Description', 'Qty / Hrs', 'Rate', 'Total', ''].map((h, i) => (
-
3 ? 'right' : 'left' }}>{h}
- ))}
-
-
-
- {items.map((item, index) => (
-
e.preventDefault()}
- onDrop={() => handleDrop(index)}
- style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 80px 120px 120px 40px', gap: 8, alignItems: 'center' }}
- >
-
{ dragItem.current = index; e.dataTransfer.effectAllowed = 'move'; }}
- style={{ cursor: 'grab', color: 'var(--text-muted)', fontSize: 14, textAlign: 'center', userSelect: 'none' }}
- >⠿
-
- {item.is_revision ? 'Revision' : 'New'}
-
-
updateItem(item.id, 'description', e.target.value)}
- style={{ margin: 0 }}
- />
-
updateItem(item.id, 'quantity', e.target.value)}
- style={{ margin: 0, textAlign: 'center' }}
- />
-
updateItem(item.id, 'unit_price', e.target.value)}
- style={{ margin: 0, textAlign: 'right' }}
- />
-
- ${((Number(item.quantity) || 0) * (Number(item.unit_price) || 0)).toFixed(2)}
-
-
removeItem(item.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--danger)', fontSize: 16, padding: 4 }}>✕
-
- ))}
-
-
-
setItems(prev => [...prev, newItem()])}>
- + Add Line Item
-
-
-
-
-
Total
-
${total.toFixed(2)}
-
-
-
-
-
-
Notes
-
setNotes(e.target.value)}
- style={{ minHeight: 80 }}
- />
-
-
-
-
- Submit Invoice
-
- navigate('/my-invoices-sub')} disabled={saving}>Cancel
-
+
);
}
diff --git a/src/pages/external/ExternalMyInvoiceDetail.jsx b/src/pages/external/ExternalMyInvoiceDetail.jsx
index 236faab..fad18c1 100644
--- a/src/pages/external/ExternalMyInvoiceDetail.jsx
+++ b/src/pages/external/ExternalMyInvoiceDetail.jsx
@@ -1,34 +1,27 @@
import { useState, useEffect } from 'react';
-import { useParams, useNavigate } from 'react-router-dom';
+import { useParams } from 'react-router-dom';
import Layout from '../../components/Layout';
+import PageLoader from '../../components/PageLoader';
import LoadingButton from '../../components/LoadingButton';
-import SortTh from '../../components/SortTh';
-import StatusBadge from '../../components/StatusBadge';
import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext';
import { generateSubcontractorPOPDF } from '../../lib/invoice';
import { useSortable } from '../../hooks/useSortable';
+import SubcontractorInvoiceDetailView from '../../components/SubcontractorInvoiceDetailView';
-const STATUS_COLOR = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
const STATUS_LABEL = { draft: 'Draft', submitted: 'Submitted', paid: 'Paid' };
-function fmt(val) {
- return `$${Number(val || 0).toFixed(2)}`;
-}
-
function invoiceTotal(items) {
- return (items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
+ return (items || []).reduce((sum, item) => sum + Number(item.unit_price || 0) * Number(item.quantity || 1), 0);
}
export default function MyInvoiceDetail() {
const { id } = useParams();
- const navigate = useNavigate();
const { currentUser } = useAuth();
const [invoice, setInvoice] = useState(null);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
- const [deleting, setDeleting] = useState(false);
const [downloading, setDownloading] = useState(false);
const [error, setError] = useState('');
const { sortKey, sortDir, toggle, sort } = useSortable('description');
@@ -40,7 +33,11 @@ export default function MyInvoiceDetail() {
.select('*, items:subcontractor_invoice_items(*)')
.eq('id', id)
.single();
- if (err || !data) { setError('Invoice not found.'); setLoading(false); return; }
+ if (err || !data) {
+ setError('Invoice not found.');
+ setLoading(false);
+ return;
+ }
setInvoice(data);
setLoading(false);
}
@@ -49,24 +46,18 @@ export default function MyInvoiceDetail() {
async function handleSubmit() {
setSubmitting(true);
+ const submittedAt = new Date().toISOString();
const { error: err } = await supabase
.from('subcontractor_invoices')
- .update({ status: 'submitted', submitted_at: new Date().toISOString() })
+ .update({ status: 'submitted', submitted_at: submittedAt })
.eq('id', id);
if (err) setError(err.message);
- else setInvoice(i => ({ ...i, status: 'submitted', submitted_at: new Date().toISOString() }));
+ else setInvoice((current) => ({ ...current, status: 'submitted', submitted_at: submittedAt }));
setSubmitting(false);
}
- async function handleDelete() {
- if (!window.confirm(`Delete invoice ${invoice.invoice_number}? This cannot be undone.`)) return;
- setDeleting(true);
- const { error: err } = await supabase.from('subcontractor_invoices').delete().eq('id', id);
- if (err) { setError(err.message); setDeleting(false); return; }
- navigate('/my-invoices-sub');
- }
-
async function handleDownload() {
+ if (!invoice) return;
setDownloading(true);
try {
const total = invoiceTotal(invoice.items);
@@ -92,13 +83,16 @@ export default function MyInvoiceDetail() {
setDownloading(false);
}
- if (loading) return
Loading...
;
+ if (loading) return
;
if (!invoice) return
{error || 'Invoice not found.'}
;
const total = invoiceTotal(invoice.items);
const sortedItems = [...(invoice.items || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
const tableItems = sort(sortedItems, (item, key) => {
- if (key === 'type') return item.task_id ? 'Task' : 'Other';
+ const typeSort = /\bR(\d{2})\b/i.test(String(item.description || ''))
+ ? Number(String(item.description).match(/\bR(\d{2})\b/i)?.[1] || 0)
+ : item.task_id ? 0 : 999;
+ if (key === 'type') return typeSort;
if (key === 'description') return item.description || '';
if (key === 'quantity') return Number(item.quantity || 0);
if (key === 'unit_price') return Number(item.unit_price || 0);
@@ -106,175 +100,59 @@ export default function MyInvoiceDetail() {
return '';
});
+ const headerActions = (
+ <>
+ {invoice.status === 'draft' ? (
+
+ Submit to Team
+
+ ) : null}
+ {invoice.status === 'paid' ? (
+
+ Download Receipt
+
+ ) : null}
+ >
+ );
+
return (
- navigate('/my-invoices-sub')}>← Back to Invoices
-
-
-
-
{invoice.invoice_number}
-
Fourge Branding
-
-
-
- {invoice.status === 'paid' && (
-
- Download Receipt
-
- )}
-
-
-
- {error && {error}
}
-
-
-
-
From
-
{currentUser?.name || 'Subcontractor'}
-
{currentUser?.email}
-
-
-
-
Invoice Details
-
-
-
Invoice #
-
{invoice.invoice_number}
-
-
-
Created
-
{new Date(invoice.created_at).toLocaleDateString()}
-
-
-
Terms
-
NET 30 from client payment
-
-
- {invoice.submitted_at && (
-
-
Submitted
-
{new Date(invoice.submitted_at).toLocaleDateString()}
-
- )}
-
- {invoice.paid_at && (
-
+
+ {currentUser?.name || 'Subcontractor'}
+ {currentUser?.email || '—'}
+ >
+ )}
+ rightCardTitle="Invoice Details"
+ rightCardBody={(
+
+
Invoice # {invoice.invoice_number}
+
Created {new Date(invoice.created_at).toLocaleDateString()}
+
Terms NET 30 from client payment
+
Status {STATUS_LABEL[invoice.status] || invoice.status}
+ {invoice.submitted_at ?
Submitted {new Date(invoice.submitted_at).toLocaleDateString()}
: null}
+
+ {invoice.paid_at ? (
+
Paid On
-
+
{new Date(invoice.paid_at).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
- )}
+ ) : null}
-
-
-
-
-
Line Items
-
-
-
-
- Type
- Description
- Qty / Hrs
- Rate
- Total
-
-
-
- {tableItems.map(item => (
-
-
-
- {item.task_id ? 'Task' : 'Other'}
-
-
- {item.description}
- {item.quantity}
- {fmt(item.unit_price)}
-
- {fmt(Number(item.unit_price) * Number(item.quantity || 1))}
-
-
- ))}
-
-
-
-
-
-
- {invoice.notes && (
-
-
Notes
-
{invoice.notes}
-
- )}
-
-
-
Actions
-
- {invoice.status === 'draft' && (
-
- Submit to Team
-
- )}
- {invoice.status === 'paid' && (
-
- Download Receipt
-
- )}
- {invoice.status !== 'paid' && (
-
- ✕
-
- )}
-
-
+ )}
+ sortKey={sortKey}
+ sortDir={sortDir}
+ onSort={toggle}
+ items={tableItems}
+ notes={invoice.notes}
+ total={total}
+ />
);
}
diff --git a/src/pages/external/ExternalMyInvoices.jsx b/src/pages/external/ExternalMyInvoices.jsx
index b37a664..2d4940c 100644
--- a/src/pages/external/ExternalMyInvoices.jsx
+++ b/src/pages/external/ExternalMyInvoices.jsx
@@ -1,16 +1,27 @@
import { useEffect, useState, useMemo } from 'react';
-import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
-import { useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout';
import StatusBadge from '../../components/StatusBadge';
import SortTh from '../../components/SortTh';
+import LoadingButton from '../../components/LoadingButton';
import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext';
import { readPageCache, writePageCache } from '../../lib/pageCache';
import { useSortable } from '../../hooks/useSortable';
+import { isCompletedVersionEligible } from '../../lib/invoiceVersionRules';
+import { popupOverlayStyle, popupSurfaceStyle } from '../../lib/popupStyles';
+import SubcontractorInvoiceForm from '../../components/SubcontractorInvoiceForm';
+import { generateSubcontractorPOPDF } from '../../lib/invoice';
const STATUS_BADGE = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
const STATUS_LABEL = { draft: 'Draft', submitted: 'Submitted', paid: 'Paid' };
+const FIELD_LABEL_STYLE = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, display: 'block', marginBottom: 4 };
+
+const STAT_ICONS = {
+ newTasks: '
',
+ revisions: '
',
+ invoiced: '
',
+ paid: '
',
+};
function fmt(val) {
return `$${Number(val || 0).toFixed(2)}`;
@@ -20,121 +31,535 @@ function invoiceTotal(items) {
return (items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
}
+function TaskStatCard({ label, value, sub, iconBg, iconColor, iconPath }) {
+ return (
+
+ );
+}
+
+function asArray(value) {
+ if (Array.isArray(value)) return value;
+ if (value == null) return [];
+ return typeof value === 'object' ? [value] : [];
+}
+
+function parseItemVersionNumber(item) {
+ if (Number.isFinite(Number(item?.version_number))) return Number(item.version_number);
+ const match = String(item?.description || '').match(/\bR(\d{2})\b/i);
+ return match ? Number(match[1]) : 0;
+}
+
+function itemVersionLabel(item) {
+ return `R${String(parseItemVersionNumber(item)).padStart(2, '0')}`;
+}
+
+function itemWorkLabel(item) {
+ return parseItemVersionNumber(item) > 0 ? 'Revision' : 'New';
+}
+
+function cleanItemDescription(item) {
+ 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) {
+ return {
+ po_number: invoice.invoice_number,
+ status: statusOverride || invoice.status || 'draft',
+ profile,
+ project: { name: 'Subcontractor Invoice', company: { name: 'Fourge Branding' } },
+ date: invoice.created_at?.split('T')[0],
+ due_date: invoice.paid_at?.split('T')[0] || invoice.submitted_at?.split('T')[0] || invoice.created_at?.split('T')[0],
+ amount: total,
+ description: invoice.status === 'paid'
+ ? 'Payment received for completed subcontractor work.'
+ : 'Subcontractor invoice for completed work.',
+ notes: invoice.notes,
+ items: (invoice.items || []).map((item, idx) => ({
+ description: item.description,
+ amount: Number(item.unit_price || 0) * Number(item.quantity || 1),
+ sort_order: item.sort_order ?? idx,
+ })),
+ };
+}
+
export default function MyInvoices() {
const { currentUser } = useAuth();
- const navigate = useNavigate();
const cacheKey = `ext-invoices:${currentUser?.id}`;
const cached = readPageCache(cacheKey, 3 * 60_000);
- const [invoices, setInvoices] = useState(() => cached || []);
+ const [invoices, setInvoices] = useState(() => cached?.invoices || []);
+ const [stats, setStats] = useState(() => cached?.stats || { completedNewTasks: 0, completedRevisions: 0, invoicedUnits: 0, paidUnits: 0 });
const [loading, setLoading] = useState(() => !cached);
const [error, setError] = useState('');
- const { sortKey, sortDir, toggle, sort } = useSortable('created_at');
+ const [showInvoiceForm, setShowInvoiceForm] = useState(false);
+ const { sortKey, sortDir, toggle, sort } = useSortable('created_at', 'desc');
+ const { sortKey: detailSortKey, sortDir: detailSortDir, toggle: toggleDetailSort, sort: sortDetailItems } = useSortable('description');
+ const [viewingInvoice, setViewingInvoice] = useState(null);
+ const [detailLoading, setDetailLoading] = useState(false);
+ const [detailError, setDetailError] = useState('');
+ const [detailTaskTypeMap, setDetailTaskTypeMap] = useState({});
+ const [submittingInvoice, setSubmittingInvoice] = useState(false);
+ const [downloadingInvoice, setDownloadingInvoice] = useState(false);
+ const [downloadingReceipt, setDownloadingReceipt] = useState(false);
useEffect(() => {
- if (!currentUser?.id) { setLoading(false); return; }
- supabase
- .from('subcontractor_invoices')
- .select('*, items:subcontractor_invoice_items(*)')
- .order('created_at', { ascending: false })
- .then(({ data, error: err }) => {
- if (err) setError(err.message);
- else { setInvoices(data || []); writePageCache(cacheKey, data || []); }
+ async function load() {
+ if (!currentUser?.id) {
+ setInvoices([]);
+ setStats({ completedNewTasks: 0, completedRevisions: 0, invoicedUnits: 0, paidUnits: 0 });
setLoading(false);
- });
- }, [currentUser?.id]); // eslint-disable-line react-hooks/exhaustive-deps
+ return;
+ }
- const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
- const chartYear = new Date().getFullYear();
- const chartData = useMemo(() => MONTHS.map((month, mi) => {
- const submittedAmt = invoices.filter(i => i.status === 'submitted' && new Date(i.created_at).getFullYear() === chartYear && new Date(i.created_at).getMonth() === mi).reduce((s, i) => s + invoiceTotal(i.items), 0);
- const paidAmt = invoices.filter(i => i.status === 'paid' && new Date(i.created_at).getFullYear() === chartYear && new Date(i.created_at).getMonth() === mi).reduce((s, i) => s + invoiceTotal(i.items), 0);
- return { month, Submitted: +submittedAmt.toFixed(2), Paid: +paidAmt.toFixed(2) };
- }), [invoices, chartYear]);
- const hasChartData = chartData.some(d => d.Submitted > 0 || d.Paid > 0);
- const totalPaid = invoices.filter(i => i.status === 'paid').reduce((s, i) => s + invoiceTotal(i.items), 0);
- const totalPending = invoices.filter(i => i.status === 'submitted').reduce((s, i) => s + invoiceTotal(i.items), 0);
+ setLoading(true);
+ setError('');
+
+ try {
+ const [{ data: invoiceRows, error: invoiceError }, { data: taskRows, error: taskError }] = await Promise.all([
+ supabase
+ .from('subcontractor_invoices')
+ .select('*, items:subcontractor_invoice_items(*)')
+ .order('created_at', { ascending: false }),
+ supabase
+ .from('tasks')
+ .select('id, title, status, current_version, assigned_to')
+ .in('status', ['not_started', 'in_progress', 'on_hold', 'client_review', 'client_approved', 'invoiced', 'paid'])
+ .eq('assigned_to', currentUser.id)
+ .order('title'),
+ ]);
+
+ if (invoiceError) throw invoiceError;
+ if (taskError) throw taskError;
+
+ const tasks = taskRows || [];
+ const invoicesData = invoiceRows || [];
+
+ const taskIds = tasks.map((task) => task.id).filter(Boolean);
+ const { data: submissionRows, error: submissionsError } = taskIds.length > 0
+ ? await supabase
+ .from('submissions')
+ .select('task_id, deliveries(version_number, sent_by, sent_at)')
+ .in('task_id', taskIds)
+ : { data: [], error: null };
+ if (submissionsError) throw submissionsError;
+
+ const deliveriesByTask = new Map();
+ for (const row of (submissionRows || [])) {
+ const taskId = row.task_id;
+ if (!taskId) continue;
+ const bucket = deliveriesByTask.get(taskId) || new Map();
+ for (const delivery of asArray(row.deliveries)) {
+ if ((delivery.sent_by || '').trim().toLowerCase() !== (currentUser.name || '').trim().toLowerCase()) continue;
+ const version = Number(delivery.version_number || 0);
+ if (!bucket.has(version)) bucket.set(version, delivery);
+ }
+ deliveriesByTask.set(taskId, bucket);
+ }
+
+ let completedNewTasks = 0;
+ let completedRevisions = 0;
+ for (const task of tasks) {
+ const versions = [...(deliveriesByTask.get(task.id)?.keys() || [])];
+ for (const version of versions) {
+ if (!isCompletedVersionEligible(task, version)) continue;
+ if (version <= 0) completedNewTasks += 1;
+ else completedRevisions += 1;
+ }
+ }
+
+ const invoiceUnits = invoicesData.reduce((sum, invoice) => sum + asArray(invoice.items).filter((item) => item.task_id).length, 0);
+ const paidUnits = invoicesData
+ .filter((invoice) => invoice.status === 'paid')
+ .reduce((sum, invoice) => sum + asArray(invoice.items).filter((item) => item.task_id).length, 0);
+
+ const nextStats = {
+ completedNewTasks,
+ completedRevisions,
+ invoicedUnits: invoiceUnits,
+ paidUnits,
+ };
+
+ setInvoices(invoicesData);
+ setStats(nextStats);
+ writePageCache(cacheKey, { invoices: invoicesData, stats: nextStats });
+ } catch (err) {
+ console.error('Failed to load subcontractor invoices:', err);
+ setError(err?.message || 'Failed to load invoices.');
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ load();
+ }, [cacheKey, currentUser?.id, currentUser?.name]);
+
+ const totalInvoiceAmount = useMemo(
+ () => invoices.reduce((sum, invoice) => sum + invoiceTotal(invoice.items), 0),
+ [invoices]
+ );
+ const paidInvoiceAmount = useMemo(
+ () => invoices.filter((invoice) => invoice.status === 'paid').reduce((sum, invoice) => sum + invoiceTotal(invoice.items), 0),
+ [invoices]
+ );
+
+ const sortedInvoices = sort(invoices, (inv, key) => {
+ if (key === 'total') return invoiceTotal(inv.items);
+ if (key === 'line_count') return asArray(inv.items).length;
+ if (key === 'submitted_at') return inv.submitted_at ? new Date(inv.submitted_at).getTime() : 0;
+ if (key === 'created_at') return inv.created_at ? new Date(inv.created_at).getTime() : 0;
+ return inv[key] || '';
+ });
+
+ async function openInvoicePopup(invoiceId) {
+ if (!invoiceId) return;
+ setDetailLoading(true);
+ setDetailError('');
+ try {
+ const { data, error: err } = await supabase
+ .from('subcontractor_invoices')
+ .select('*, items:subcontractor_invoice_items(*)')
+ .eq('id', invoiceId)
+ .single();
+ 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);
+ } catch (err) {
+ setDetailError(err?.message || 'Failed to load invoice.');
+ } finally {
+ setDetailLoading(false);
+ }
+ }
+
+ async function handleSubmitInvoice() {
+ if (!viewingInvoice?.id) return;
+ setSubmittingInvoice(true);
+ setDetailError('');
+ try {
+ const submittedAt = new Date().toISOString();
+ const { error: err } = await supabase
+ .from('subcontractor_invoices')
+ .update({ status: 'submitted', submitted_at: submittedAt })
+ .eq('id', viewingInvoice.id);
+ if (err) throw err;
+ setViewingInvoice((current) => current ? { ...current, status: 'submitted', submitted_at: submittedAt } : current);
+ setInvoices((current) => current.map((invoice) => invoice.id === viewingInvoice.id ? { ...invoice, status: 'submitted', submitted_at: submittedAt } : invoice));
+ } catch (err) {
+ setDetailError(err?.message || 'Failed to submit invoice.');
+ } finally {
+ setSubmittingInvoice(false);
+ }
+ }
+
+ async function handleDownloadReceipt() {
+ if (!viewingInvoice) return;
+ setDownloadingReceipt(true);
+ setDetailError('');
+ try {
+ const total = invoiceTotal(viewingInvoice.items);
+ await generateSubcontractorPOPDF(
+ buildPDFArgs(viewingInvoice, { name: currentUser?.name, email: currentUser?.email }, total, 'paid')
+ );
+ } catch (err) {
+ setDetailError(err?.message || 'Failed to download receipt.');
+ } finally {
+ setDownloadingReceipt(false);
+ }
+ }
+
+ async function handleDownloadInvoice() {
+ if (!viewingInvoice) return;
+ setDownloadingInvoice(true);
+ setDetailError('');
+ try {
+ const total = invoiceTotal(viewingInvoice.items);
+ await generateSubcontractorPOPDF(
+ buildPDFArgs(viewingInvoice, { name: currentUser?.name, email: currentUser?.email }, total)
+ );
+ } catch (err) {
+ setDetailError(err?.message || 'Failed to download invoice.');
+ } finally {
+ setDownloadingInvoice(false);
+ }
+ }
return (
-
-
-
-
Invoices
-
Submit invoices to Fourge Branding for your completed work.
-
Payment terms: NET 30 from the date Fourge receives payment from the client.
+
+
+ {error &&
{error}
}
+
+
+
+
+
+ invoice.status === 'paid').length} paid invoices`}
+ iconBg="rgba(74,222,128,0.15)"
+ iconColor="#4ade80"
+ iconPath={STAT_ICONS.paid}
+ />
-
navigate('/my-invoices-sub/new')} disabled={loading}>
- + New Invoice
-
+
+ {invoices.length === 0 ? (
+ <>
+
+
+ setShowInvoiceForm(true)} disabled={loading}>
+ + Invoice
+
+
+
+
No invoices
+ >
+ ) : (
+ <>
+
+
+ setShowInvoiceForm(true)} disabled={loading}>
+ + Invoice
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Invoice #
+ Created
+ Submitted
+ Status
+ Items
+ Total
+
+
+
+ {sortedInvoices.map((inv) => {
+ const total = invoiceTotal(inv.items);
+ return (
+ openInvoicePopup(inv.id)}>
+ {inv.invoice_number || 'Subcontractor Invoice'}
+ {inv.created_at ? new Date(inv.created_at).toLocaleDateString() : '—'}
+ {inv.submitted_at ? new Date(inv.submitted_at).toLocaleDateString() : '—'}
+
+ {asArray(inv.items).length}
+ {fmt(total)}
+
+ );
+ })}
+
+
+
+
+ >
+ )}
- {error && {error}
}
-
- {!loading && invoices.length > 0 && (
- <>
-
-
-
-
+ {showInvoiceForm && (
+
setShowInvoiceForm(false)}>
+
e.stopPropagation()}
+ >
+ setShowInvoiceForm(false)}
+ onSubmitted={(inv) => {
+ setShowInvoiceForm(false);
+ openInvoicePopup(inv.id);
+ }}
+ />
- {hasChartData && (
-
-
-
-
-
-
-
-
-
- `$${v >= 1000 ? (v/1000).toFixed(0)+'k' : v}`} width={45} />
- [`$${Number(v).toLocaleString('en-US', { minimumFractionDigits: 2 })}`, undefined]} contentStyle={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, fontSize: 12 }} />
-
-
-
-
-
-
- )}
- >
+
)}
- {loading ? (
-
Loading invoices...
- ) : invoices.length === 0 ? (
-
-
No invoices yet
-
Create your first invoice to get paid for your completed work.
-
- ) : (
-
-
-
-
-
- Invoice #
- Submitted
- Status
- Total
-
-
-
- {sort(invoices, (inv, key) => {
- if (key === 'total') return invoiceTotal(inv.items);
- if (key === 'submitted_at') return inv.submitted_at ? new Date(inv.submitted_at).getTime() : 0;
- return inv[key] || '';
- }).map(inv => {
- const total = invoiceTotal(inv.items);
- return (
- navigate(`/my-invoices-sub/${inv.id}`)}>
- {inv.invoice_number}
- {inv.submitted_at ? new Date(inv.submitted_at).toLocaleDateString() : '—'}
-
- {fmt(total)}
-
- );
- })}
-
-
+ {(viewingInvoice || detailLoading || detailError) && (
+
{ if (!submittingInvoice && !downloadingInvoice && !downloadingReceipt) { setViewingInvoice(null); setDetailError(''); } }}>
+
e.stopPropagation()}
+ >
+ {detailLoading && !viewingInvoice ? (
+
Loading invoice…
+ ) : viewingInvoice ? (() => {
+ const total = invoiceTotal(viewingInvoice.items);
+ const sortedItems = [...(viewingInvoice.items || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
+ const invoiceStatus = viewingInvoice.status ? viewingInvoice.status.charAt(0).toUpperCase() + viewingInvoice.status.slice(1) : '—';
+ return (
+ <>
+ {detailError ?
{detailError}
: null}
+
+
+
+
{viewingInvoice.invoice_number || 'Subcontractor Invoice'}
+
+ {currentUser?.name || 'Subcontractor'}{currentUser?.email ? ` · ${currentUser.email}` : ''}
+
+
+
+
+
+
+
+
Created
+
{viewingInvoice.created_at ? new Date(viewingInvoice.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}
+
+
+
Submitted
+
{viewingInvoice.submitted_at ? new Date(viewingInvoice.submitted_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}
+
+
+
Line Items
+
{sortedItems.length}
+
+
+
Total
+
${total.toFixed(2)}
+
+
+
+
+ {sortedItems.length === 0 ? (
+
No line items
+ ) : (
+
+
+
+
+
+
+
+
+
+
+
+
+ Work
+ R#
+ Description
+ Type
+ Qty
+ Unit Price
+ Amount
+
+
+
+ {sortedItems.map(item => (
+
+ {itemWorkLabel(item)}
+ {itemVersionLabel(item)}
+ {cleanItemDescription(item)}
+ {detailTaskTypeMap[item.task_id] || 'Other'}
+ {item.quantity || 1}
+ ${Number(item.unit_price || 0).toFixed(2)}
+ ${(Number(item.unit_price || 0) * Number(item.quantity || 1)).toFixed(2)}
+
+ ))}
+
+
+ )}
+
+ {viewingInvoice.notes && (
+
+
Notes
+
{viewingInvoice.notes}
+
+ )}
+
+
+
+
+ Download Invoice
+
+ {viewingInvoice.status === 'draft' && (
+
+ Submit to Team
+
+ )}
+ {viewingInvoice.status === 'paid' && (
+
+ Download Receipt
+
+ )}
+ { setViewingInvoice(null); setDetailError(''); }} disabled={submittingInvoice || downloadingInvoice || downloadingReceipt}>
+ Cancel
+
+
+ >
+ );
+ })() : (
+
{detailError || 'Invoice not found.'}
+ )}
)}
diff --git a/src/pages/external/ExternalMyPurchaseOrders.jsx b/src/pages/external/ExternalMyPurchaseOrders.jsx
index 7aba092..b52dc5c 100644
--- a/src/pages/external/ExternalMyPurchaseOrders.jsx
+++ b/src/pages/external/ExternalMyPurchaseOrders.jsx
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import Layout from '../../components/Layout';
+import PageLoader from '../../components/PageLoader';
import StatusBadge from '../../components/StatusBadge';
import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext';
@@ -95,14 +96,11 @@ export default function MyPurchaseOrders() {
{loading ? (
-
Loading...
+
) : error ? (
{error}
) : purchaseOrders.length === 0 ? (
-
-
No purchase orders
-
New POs will appear here when the Fourge team sends them.
-
+
No purchase orders
) : (
{purchaseOrders.map(po => (
diff --git a/src/pages/team/TeamCreateInvoice.jsx b/src/pages/team/TeamCreateInvoice.jsx
index 90fad4b..5222046 100644
--- a/src/pages/team/TeamCreateInvoice.jsx
+++ b/src/pages/team/TeamCreateInvoice.jsx
@@ -7,6 +7,8 @@ import { useAuth } from '../../context/AuthContext';
import { generateInvoicePDF } from '../../lib/invoice';
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
import { withTimeout } from '../../lib/withTimeout';
+import { isReviewShadowDescription } from '../../lib/taskVersions';
+import { getRevisionChargeQuantity, isCompletedVersionEligible, isInitialVersionEligible } from '../../lib/invoiceVersionRules';
// Computed at module load time — stable for the lifetime of the invoice creation session
const INVOICE_TODAY = new Date().toISOString().split('T')[0];
@@ -29,12 +31,6 @@ const buildRevisionItemDescription = (revision) => {
return `${projectName} • ${taskTitle} • Revision ${versionLabel}`;
};
-const getRevisionChargeQuantity = (revision) => {
- const version = Number(revision?.version_number || 0);
- // Incremental billing: R01 is free, each uninvoiced revision from R02+ is one charge unit.
- return version >= 2 ? 1 : 0;
-};
-
export default function CreateInvoice() {
const navigate = useNavigate();
const { currentUser } = useAuth();
@@ -96,30 +92,30 @@ export default function CreateInvoice() {
setInvoiceEmail(recipients[0]?.email || companies.find(c => c.id === selectedCompanyId)?.contact_email || '');
if (projects && projects.length > 0) {
const projectIds = projects.map(p => p.id);
- const { data: tasks } = await supabase
+ const { data: companyTasks } = await supabase
.from('tasks')
.select('*, project:projects(name), submissions(service_type, type, version_number)')
.in('project_id', projectIds)
- .eq('invoiced', false)
- .eq('status', 'client_approved');
- const approvedTaskIds = (tasks || []).map((t) => t.id).filter(Boolean);
- const { data: revisions } = approvedTaskIds.length > 0
+ .in('status', ['not_started', 'in_progress', 'on_hold', 'client_review', 'client_approved', 'invoiced', 'paid']);
+ const companyTaskIds = (companyTasks || []).map((t) => t.id).filter(Boolean);
+ const { data: revisions } = companyTaskIds.length > 0
? await supabase
.from('submissions')
- .select('*, task:tasks(id, title, project:projects(name), submissions(service_type, type))')
+ .select('*, task:tasks(id, title, status, current_version, project:projects(name), submissions(service_type, type))')
.eq('type', 'revision')
- .or('revision_type.eq.client_revision,revision_type.is.null')
.eq('invoiced', false)
- .in('task_id', approvedTaskIds)
+ .in('task_id', companyTaskIds)
+ .order('submitted_at', { ascending: false })
: { data: [] };
- const tasksWithService = (tasks || []).map(t => {
+ const tasksWithService = (companyTasks || []).filter(isInitialVersionEligible).map(t => {
const initial = (t.submissions || []).find(s => s.type === 'initial') || (t.submissions || [])[0];
return { ...t, service_type: initial?.service_type || t.title };
});
setUninvoicedTasks(tasksWithService);
// Deduplicate by (task_id, version_number) — multiple submission rows per version (e.g. "Add Files") must not produce multiple invoice charges
const revMap = new Map();
- for (const rev of (revisions || [])) {
+ for (const rev of (revisions || []).filter(rev => !isReviewShadowDescription(rev.description))) {
+ if (!isCompletedVersionEligible(rev.task, rev.version_number)) continue;
const key = `${rev.task_id}:${rev.version_number}`;
if (!revMap.has(key)) revMap.set(key, rev);
}
@@ -152,7 +148,7 @@ export default function CreateInvoice() {
const serviceLabel = getRevisionServiceType(revision);
const description = buildRevisionItemDescription(revision);
const price = priceList.find(p => p.service_type === serviceLabel && p.price_type === 'revision');
- const revisionChargeQty = getRevisionChargeQuantity(revision);
+ const revisionChargeQty = getRevisionChargeQuantity(revision?.version_number);
const quantity = revisionChargeQty > 0 ? revisionChargeQty : 1;
const unitPrice = revisionChargeQty > 0 ? (price?.price || '') : 0;
setItems(prev => {
@@ -324,7 +320,7 @@ export default function CreateInvoice() {
return (
- navigate('/invoices')}>← Back to Invoices
+ navigate('/finances')}>← Back to Invoices
@@ -403,9 +399,10 @@ export default function CreateInvoice() {
{buildNewItemDescription(task)}
-
- {task.service_type || 'Other'} • {price ? `$${Number(price.price).toFixed(2)}` : 'No price set'}
-
+
+ New
+ {task.service_type || 'Other'} • {price ? `$${Number(price.price).toFixed(2)}` : 'No price set'}
+
{
const revServiceType = getRevisionServiceType(rev);
const price = priceList.find(p => p.service_type === revServiceType && p.price_type === 'revision');
+ const revisionChargeQty = getRevisionChargeQuantity(rev?.version_number);
const alreadyAdded = items.some(i => i.submission_id === rev.id);
return (
{buildRevisionItemDescription(rev)}
-
- {revServiceType || 'Other'} • {price ? `$${Number(price.price).toFixed(2)}` : 'No price set'}
+
+ Revision
+ {revServiceType || 'Other'} • {revisionChargeQty > 0 ? (price ? `$${Number(price.price).toFixed(2)}` : 'No price set') : 'Free'}
handleSave('sent')} loading={saving} loadingText="Finalizing & Sending...">
Finalise & Send
- navigate('/invoices')}>Cancel
+ navigate('/finances')}>Cancel
);
diff --git a/src/pages/team/TeamCreateSubcontractorPO.jsx b/src/pages/team/TeamCreateSubcontractorPO.jsx
index 2120050..01256c4 100644
--- a/src/pages/team/TeamCreateSubcontractorPO.jsx
+++ b/src/pages/team/TeamCreateSubcontractorPO.jsx
@@ -3,7 +3,6 @@ import { useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout';
import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext';
-import { sendEmail } from '../../lib/email';
const FIELD_LABEL_STYLE = { fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', marginBottom: 4 };
const FIELD_INPUT_STYLE = { minHeight: 42, margin: 0 };
@@ -180,35 +179,12 @@ export default function CreateSubcontractorPO() {
return;
}
- if (status === 'sent') {
- const subcontractor = externalProfiles.find(profile => profile.id === form.profile_id);
- const project = projects.find(row => row.id === form.project_id);
- if (subcontractor?.email) {
- try {
- await sendEmail('subcontractor_po_sent', subcontractor.email, {
- poNumber: data.po_number || 'Purchase Order',
- subcontractorName: subcontractor.name || 'there',
- projectName: project?.name || 'Subcontractor Work',
- companyName: project?.company?.name || 'Fourge Branding',
- amount: `$${total.toFixed(2)}`,
- dueDate: form.due_date ? new Date(form.due_date).toLocaleDateString() : 'Not set',
- terms: form.terms.trim() || 'Net 15',
- scope: lineItems.map(item => `${item.description} - $${item.amount.toFixed(2)}`).join('\n'),
- portalUrl: 'https://portal.fourgebranding.com/my-purchase-orders',
- });
- } catch (emailError) {
- alert(`PO was finalized, but the email failed: ${emailError.message || 'unknown error'}`);
- }
- } else {
- alert('PO was finalized, but this subcontractor has no email on file.');
- }
- }
- navigate('/invoices', { state: { tab: 'subcontractor-po' } });
+ navigate('/finances', { state: { tab: 'subcontractor-po' } });
};
return (
-
- navigate('/invoices', { state: { tab: 'subcontractor-po' } })}>← Back to Subcontractor PO
+
+ navigate('/finances', { state: { tab: 'subcontractor-po' } })}>← Back to Subcontractor PO
diff --git a/src/pages/team/TeamDashboard.jsx b/src/pages/team/TeamDashboard.jsx
index 965be44..136aefe 100644
--- a/src/pages/team/TeamDashboard.jsx
+++ b/src/pages/team/TeamDashboard.jsx
@@ -1,15 +1,18 @@
-import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
+import { useState, useEffect, useMemo, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout';
+import PageLoader from '../../components/PageLoader';
import SortTh from '../../components/SortTh';
+import ProfileAvatar from '../../components/ProfileAvatar';
import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext';
import { readPageCache, writePageCache } from '../../lib/pageCache';
import { withTimeout } from '../../lib/withTimeout';
import { getDeadlineSourceSubmission } from '../../lib/taskDeadlines';
import { useSortable } from '../../hooks/useSortable';
-import { useRefetchOnFocus } from '../../hooks/useRefetchOnFocus';
-import { useRealtimeSubscription } from '../../hooks/useRealtimeSubscription';
+import { useLiveRefresh } from '../../hooks/useLiveRefresh';
+import { resolveScopedWorkIds } from '../../lib/workScope';
+import { getSubmissionDisplayName, mergeSubmissionDisplayNames } from '../../lib/submissionDisplay';
// ─── Helpers ──────────────────────────────────────────────────────────────
@@ -32,22 +35,6 @@ function fmtMoney(n) {
return `$${Number(n).toFixed(2)}`;
}
-function buildClientHighlights(companies, projects, tasks, clientProfiles, companyMemberships = [], invoices = []) {
- const doneStatuses = ['client_approved', 'invoiced', 'paid'];
- return (companies || []).map(company => {
- const companyProjects = (projects || []).filter(p => p.company_id === company.id);
- const primaryContact = (clientProfiles || []).find(p =>
- p.company_id === company.id ||
- (companyMemberships || []).some(m => m.company_id === company.id && m.profile_id === p.id)
- );
- const openTasks = (tasks || []).filter(t => companyProjects.some(p => p.id === t.project_id) && !doneStatuses.includes(t.status));
- const companyInvoices = (invoices || []).filter(i => i.company_id === company.id);
- const outstandingTotal = companyInvoices.filter(i => i.status === 'sent').reduce((s, i) => s + Number(i.total || 0), 0);
- const paidTotal = companyInvoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total || 0), 0);
- return { company, primaryContact, projectCount: companyProjects.length, openTaskCount: openTasks.length, outstandingTotal, paidTotal };
- });
-}
-
const ACTION_ICON = {
task_started: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path:
},
task_resumed: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path:
},
@@ -75,25 +62,6 @@ function ActionIcon({ actionKey, size = 27 }) {
);
}
-function Avatar({ name, size = 27 }) {
- return (
-
- );
-}
-
-function InitialPortrait({ name }) {
- return (
-
- {(name || '?')[0].toUpperCase()}
-
- );
-}
-
function smoothCurve(pts) {
if (pts.length < 2) return '';
let d = `M${pts[0][0].toFixed(1)},${pts[0][1].toFixed(1)}`;
@@ -373,170 +341,159 @@ function TasksInProgressCard({ tasks = [] }) {
);
}
-function HotItemsCard({ submissions, tasks, isClient = false }) {
+function HotItemsCard({ submissions, tasks, isClient = false, isExternal = false, currentUserId = null, cardHeight = null }) {
const navigate = useNavigate();
const { sortKey, sortDir, toggle, sort } = useSortable('deadline');
const taskMap = new Map((tasks || []).map(t => [t.id, t]));
const seen = new Set();
- const hotItems = (submissions || [])
- .filter(s => {
- const task = taskMap.get(s.task_id);
- if (isClient) return task?.status === 'client_review' && !seen.has(s.task_id) && seen.add(s.task_id);
- const activeStatuses = ['not_started', 'in_progress', 'client_review', 'on_hold'];
- return s.is_hot && activeStatuses.includes(task?.status) && !seen.has(s.task_id) && seen.add(s.task_id);
- })
- .map(s => ({ ...s, task: taskMap.get(s.task_id) }))
- .filter(s => s.task)
- .sort((a, b) => {
- if (!a.deadline && !b.deadline) return 0;
- if (!a.deadline) return 1;
- if (!b.deadline) return -1;
- return new Date(a.deadline) - new Date(b.deadline);
- })
- .slice(0, 7);
+ const activeStatuses = ['not_started', 'in_progress', 'client_review', 'on_hold'];
+ const hotItems = isExternal
+ ? (tasks || [])
+ .filter(task => task?.assigned_to === currentUserId && activeStatuses.includes(task?.status))
+ .sort((a, b) => {
+ if (!a.deadline && !b.deadline) return 0;
+ if (!a.deadline) return 1;
+ if (!b.deadline) return -1;
+ return new Date(a.deadline) - new Date(b.deadline);
+ })
+ .slice(0, 8)
+ : (submissions || [])
+ .filter(s => {
+ const task = taskMap.get(s.task_id);
+ if (isClient) return task?.status === 'client_review' && !seen.has(s.task_id) && seen.add(s.task_id);
+ return s.is_hot && activeStatuses.includes(task?.status) && !seen.has(s.task_id) && seen.add(s.task_id);
+ })
+ .map(s => ({ ...s, task: taskMap.get(s.task_id) }))
+ .filter(s => s.task)
+ .sort((a, b) => {
+ if (!a.deadline && !b.deadline) return 0;
+ if (!a.deadline) return 1;
+ if (!b.deadline) return -1;
+ return new Date(a.deadline) - new Date(b.deadline);
+ })
+ .slice(0, 7);
const sortedHotItems = sort(hotItems, (s, key) => {
+ if (isExternal) {
+ if (key === 'task') return s.title || '';
+ if (key === 'project') return s.project_name || '';
+ if (key === 'status') return s.status || '';
+ if (key === 'deadline') return s.deadline || '';
+ return '';
+ }
if (key === 'task') return s.task?.title || '';
- if (key === 'requested_by') return s.submitted_by_name || '';
+ if (key === 'requested_by') return getSubmissionDisplayName(s) || '';
if (key === 'deadline') return s.deadline || '';
return '';
});
return (
-
+
0 ? 14 : 0, flexShrink: 0 }}>
- {isClient ? 'Tasks Ready For Review' : 'Hot Tasks'}
+ {isClient ? 'Tasks Ready For Review' : isExternal ? 'My Tasks' : 'Hot Tasks'}
{hotItems.length === 0 ? (
-
- {isClient ? 'No tasks ready for review' : 'No hot tasks'}
+
+ {isClient ? 'No tasks ready for review' : isExternal ? 'No active tasks assigned to you' : 'No hot tasks'}
) : (
-
-
-
-
-
-
-
-
-
-
- Task
- Requested By
- Due By
-
-
-
- {sortedHotItems.map(s => (
-
-
- {s.task.status === 'client_approved' ? (
-
-
-
- ) : (
-
-
-
- )}
-
-
- navigate('/tasks/' + s.task_id)}>{s.task.title}
-
-
- {s.submitted_by ? (
- navigate(profilePath(s.submitted_by, s.submitter_role))}>{s.submitted_by_name || 'Profile'}
- ) : (s.submitted_by_name || '—')}
-
- {s.deadline ? new Date(s.deadline).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '—'}
-
- ))}
-
-
+
+
+ {isExternal ? (
+ <>
+
+
+
+
+
+
+
+
+ Task
+ Project
+ Status
+ Due By
+
+
+
+ {sortedHotItems.map((task) => (
+
+
+ navigate('/tasks/' + task.id)}>{task.title}
+
+ {task.project?.name || '—'}
+ {String(task.status || '').replace(/_/g, ' ') || '—'}
+ {task.deadline ? new Date(task.deadline).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '—'}
+
+ ))}
+
+ >
+ ) : (
+ <>
+
+
+
+
+
+
+
+
+
+ Task
+ Requested By
+ Due By
+
+
+
+ {sortedHotItems.map(s => (
+
+
+ {s.task.status === 'client_approved' ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+ navigate('/tasks/' + s.task_id)}>{s.task.title}
+
+
+ {s.submitted_by ? (
+ navigate(profilePath(s.submitted_by, s.submitter_role))}>{getSubmissionDisplayName(s) || 'Profile'}
+ ) : (getSubmissionDisplayName(s) || '—')}
+
+ {s.deadline ? new Date(s.deadline).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '—'}
+
+ ))}
+
+ >
+ )}
+
+
)}
);
}
-function ClientHighlightCard({ highlights }) {
- const navigate = useNavigate();
- const { sortKey, sortDir, toggle, sort } = useSortable('company');
- const fmt = (n) => `$${Number(n || 0).toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`;
- const thStyle = { fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' };
- const td = () => ({ padding: '5px', border: 'none', background: 'transparent', textAlign: 'center', verticalAlign: 'middle' });
- const sortedHighlights = sort(highlights || [], (row, key) => {
- if (key === 'company') return row.company?.name || '';
- if (key === 'contact') return row.primaryContact?.name || '';
- if (key === 'projects') return row.projectCount || 0;
- if (key === 'open_tasks') return row.openTaskCount || 0;
- if (key === 'outstanding') return Number(row.outstandingTotal || 0);
- if (key === 'paid') return Number(row.paidTotal || 0);
- return '';
- });
- return (
-
-
0 ? 14 : 0, flexShrink: 0 }}>
- Client Highlight
-
- {!highlights || highlights.length === 0 ? (
-
No data
- ) : (
-
-
-
-
-
-
-
-
-
-
-
-
-
- Company
- Contact
- Projects
- Open Tasks
- Outstanding
- Paid
-
-
-
- {sortedHighlights.map(({ company, primaryContact, projectCount, openTaskCount, outstandingTotal = 0, paidTotal = 0 }) => (
-
-
-
-
-
- navigate(`/company/${company.id}`)}>{company.name}
-
-
- {primaryContact?.id ? (
- navigate(profilePath(primaryContact.id, primaryContact.role))}>{primaryContact.name || 'Profile'}
- ) : (primaryContact?.name || '—')}
-
- {projectCount}
- {openTaskCount || 0}
- {fmt(outstandingTotal)}
- {fmt(paidTotal)}
-
- ))}
-
-
- )}
-
- );
-}
-
-function TeamPerformanceCard({ tasks, profiles, deliveries }) {
+function TeamPerformanceCard({ tasks, profiles, deliveries, cardHeight = null }) {
const navigate = useNavigate();
const profileMap = useMemo(() => {
const m = new Map();
(profiles || []).forEach(p => m.set(p.id, p));
return m;
}, [profiles]);
+ const profileNameMap = useMemo(() => {
+ const m = new Map();
+ (profiles || []).forEach((profile) => {
+ const key = String(profile?.name || '').trim().toLowerCase();
+ if (key && !m.has(key)) m.set(key, profile);
+ });
+ return m;
+ }, [profiles]);
const doneStatuses = ['client_approved', 'invoiced', 'paid'];
const toEST = (dateStr) => new Date(new Date(dateStr).toLocaleString('en-US', { timeZone: 'America/New_York' }));
const monthsWithData = useMemo(() => new Set(
@@ -556,9 +513,12 @@ function TeamPerformanceCard({ tasks, profiles, deliveries }) {
}, []);
const monthOpts = allMonthOpts.filter(o => monthsWithData.has(o.value));
const [monthKey, setMonthKey] = useState(() => monthOpts[0]?.value || allMonthOpts[0].value);
+ const effectiveMonthKey = monthOpts.some(option => option.value === monthKey)
+ ? monthKey
+ : (monthOpts[0]?.value || allMonthOpts[0]?.value || '');
const { people, totalDone } = useMemo(() => {
- const [year, month] = monthKey.split('-').map(Number);
+ const [year, month] = effectiveMonthKey.split('-').map(Number);
const map = new Map();
(deliveries || []).forEach(d => {
const task = d.submission?.task;
@@ -567,35 +527,50 @@ function TeamPerformanceCard({ tasks, profiles, deliveries }) {
if (dt.getFullYear() !== year || dt.getMonth() + 1 !== month) return;
const name = d.sent_by || task.assigned_name;
if (!name) return;
- const entry = map.get(name) || { name, id: task.assigned_to || null, newCount: 0, revCount: 0 };
+ const matchedProfile = (task.assigned_to && profileMap.get(task.assigned_to)) || profileNameMap.get(String(name).trim().toLowerCase()) || null;
+ const entry = map.get(name) || {
+ name,
+ id: matchedProfile?.id || task.assigned_to || null,
+ avatar_url: matchedProfile?.avatar_url || '',
+ newCount: 0,
+ revCount: 0,
+ };
if ((d.version_number || 0) === 0) entry.newCount += 1;
else entry.revCount += 1;
map.set(name, entry);
});
const people = [...map.values()].map(p => ({ ...p, done: p.newCount + p.revCount })).sort((a, b) => b.done - a.done);
return { people, totalDone: people.reduce((s, p) => s + p.done, 0) };
- }, [deliveries, monthKey]); // eslint-disable-line react-hooks/exhaustive-deps
+ }, [deliveries, effectiveMonthKey, profileMap, profileNameMap]); // eslint-disable-line react-hooks/exhaustive-deps
return (
-
+
Team Performance
-
setMonthKey(e.target.value)} style={{ fontSize: 11, color: 'var(--text-muted)', background: 'transparent', border: 'none', boxShadow: 'none', cursor: 'pointer', outline: 'none', fontFamily: 'inherit', appearance: 'none', WebkitAppearance: 'none', MozAppearance: 'none', paddingRight: 14 }}>
+ setMonthKey(e.target.value)} style={{ fontSize: 11, color: 'var(--text-muted)', background: 'transparent', border: 'none', boxShadow: 'none', cursor: 'pointer', outline: 'none', fontFamily: 'inherit', appearance: 'none', WebkitAppearance: 'none', MozAppearance: 'none', paddingRight: 14 }}>
{monthOpts.map(o => {o.label} )}
{people.length === 0 ? (
-
No completed tasks this month
+
No completed tasks this month
) : (
-
+
{people.slice(0, 5).map((p, i) => {
const pct = totalDone > 0 ? Math.round((p.done / totalDone) * 100) : 0;
return (
0 ? 10 : 0 }}>
- {(() => { const prof = p.id ? profileMap.get(p.id) : null; return prof?.avatar_url ?
:
; })()}
+
{p.id ?
navigate(`/profile/${p.id}`)}>{p.name} :
{p.name} }
@@ -628,26 +603,40 @@ export default function TeamDashboard() {
const isClient = currentUser?.role === 'client';
const isExternal = currentUser?.role === 'external';
- const [refreshKey, setRefreshKey] = useState(0);
- const refresh = useCallback(() => setRefreshKey(k => k + 1), []);
- useRefetchOnFocus(refresh);
- useRealtimeSubscription(['tasks', 'projects', 'submissions', 'activity_log', 'profiles'], refresh);
+ const { refreshKey } = useLiveRefresh(['tasks', 'projects', 'submissions', 'activity_log', 'profiles']);
const cached = isTeam ? readPageCache(CACHE_KEY, 5 * 60_000) : null;
const [tasks, setTasks] = useState(() => cached?.tasks || []);
const [projects, setProjects] = useState(() => cached?.projects || []);
const [submissions, setSubmissions] = useState(() => cached?.submissions || []);
- const [allCompanies, setAllCompanies] = useState(() => cached?.companies || []);
- const [clientProfiles, setClientProfiles] = useState(() => cached?.clientProfiles || []);
- const [companyMemberships, setCompanyMemberships] = useState(() => cached?.companyMemberships || []);
const [activityLog, setActivityLog] = useState(() => cached?.activityLog || []);
const [allProfiles, setAllProfiles] = useState(() => cached?.allProfiles || []);
const [teamInvoices, setTeamInvoices] = useState(() => cached?.teamInvoices || []);
const [teamExpenses, setTeamExpenses] = useState([]);
+ const [teamSubcontractorPOs, setTeamSubcontractorPOs] = useState([]);
+ const [teamSubInvoices, setTeamSubInvoices] = useState([]);
const [myInvoices, setMyInvoices] = useState([]);
const [perfDeliveries, setPerfDeliveries] = useState([]);
const [loading, setLoading] = useState(!cached);
+ const bottomCardsRowRef = useRef(null);
+ const [bottomCardsHeight, setBottomCardsHeight] = useState(null);
+
+ useEffect(() => {
+ function updateBottomCardsHeight() {
+ const row = bottomCardsRowRef.current;
+ if (!row) return;
+ const rect = row.getBoundingClientRect();
+ const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0;
+ const available = Math.floor(viewportHeight - rect.top - 24);
+ const nextHeight = available > 0 ? `${available}px` : null;
+ setBottomCardsHeight(prev => (prev === nextHeight ? prev : nextHeight));
+ }
+
+ updateBottomCardsHeight();
+ window.addEventListener('resize', updateBottomCardsHeight);
+ return () => window.removeEventListener('resize', updateBottomCardsHeight);
+ }, [loading, isTeam, isClient, isExternal]);
useEffect(() => {
let cancelled = false;
@@ -658,37 +647,16 @@ export default function TeamDashboard() {
cutoff.setMonth(cutoff.getMonth() - CUTOFF_MONTHS);
const cutoffStr = cutoff.toISOString();
- let scopedTaskIds = null;
- let scopedProjectIds = null;
- let scopedCompanyIds = null;
-
- if (!isTeam && isClient) {
- const companyIds = [...new Set([...(currentUser?.companies?.map(c => c.company?.id || c.id) || []), ...(currentUser?.company_id ? [currentUser.company_id] : [])])].filter(Boolean);
- scopedCompanyIds = companyIds;
- if (companyIds.length > 0) {
- const { data: projRows } = await supabase.from('projects').select('id, tasks(id)').in('company_id', companyIds);
- scopedProjectIds = (projRows || []).map(p => p.id).filter(Boolean);
- scopedTaskIds = (projRows || []).flatMap(p => (p.tasks || []).map(t => t.id)).filter(Boolean);
- } else {
- scopedProjectIds = [];
- scopedTaskIds = [];
- }
- }
-
- if (!isTeam && isExternal) {
- const uid = currentUser?.id;
- const { data: memberData } = await supabase.from('project_members').select('project_id').eq('profile_id', uid);
- scopedProjectIds = (memberData || []).map(m => m.project_id).filter(Boolean);
- if ((scopedProjectIds || []).length > 0) {
- const { data: projectTasks } = await supabase.from('tasks').select('id').in('project_id', scopedProjectIds);
- scopedTaskIds = (projectTasks || []).map(row => row.id).filter(Boolean);
- } else {
- scopedTaskIds = [];
- }
- }
+ const {
+ scopedTaskIds,
+ scopedProjectIds,
+ scopedCompanyIds,
+ } = isTeam
+ ? { scopedTaskIds: null, scopedProjectIds: null, scopedCompanyIds: null }
+ : await resolveScopedWorkIds(currentUser, { isClient, isExternal });
const tasksQuery = supabase.from('tasks')
- .select('id, title, status, current_version, project_id, assigned_name, assigned_to, completed_at, assignee:profiles!assigned_to(avatar_url)')
+ .select('id, title, status, current_version, project_id, assigned_name, assigned_to, completed_at, project:projects(name), assignee:profiles!assigned_to(avatar_url)')
.gte('submitted_at', cutoffStr)
.order('submitted_at', { ascending: false });
if (isClient) {
@@ -724,10 +692,10 @@ export default function TeamDashboard() {
}
const invoicesPromise = isTeam
- ? supabase.from('invoices').select('total, status, company_id, created_at').in('status', ['sent', 'paid'])
+ ? supabase.from('invoices').select('total, stripe_fee, status, company_id, created_at').in('status', ['sent', 'paid'])
: isClient
? ((scopedCompanyIds || []).length > 0
- ? supabase.from('invoices').select('total, status, company_id, created_at').in('company_id', scopedCompanyIds).in('status', ['sent', 'paid'])
+ ? supabase.from('invoices').select('total, stripe_fee, status, company_id, created_at').in('company_id', scopedCompanyIds).in('status', ['sent', 'paid'])
: Promise.resolve({ data: [] }))
: supabase.from('subcontractor_invoices').select('total, status').eq('profile_id', currentUser?.id).in('status', ['submitted', 'approved', 'paid']);
@@ -735,17 +703,25 @@ export default function TeamDashboard() {
? supabase.from('expenses').select('amount')
: Promise.resolve({ data: [] });
- const [{ data: t }, { data: p }, { data: subs }, { data: profiles }, { data: activity }, { data: cos }, { data: memRows }, { data: invs }, { data: exps }, { data: delivs }] = await withTimeout(Promise.all([
+ const subcontractorPOsPromise = isTeam
+ ? supabase.from('subcontractor_payments').select('amount, status, paid_at, date')
+ : Promise.resolve({ data: [] });
+
+ const subcontractorInvoicesPromise = isTeam
+ ? supabase.from('subcontractor_invoices').select('status, paid_at, items:subcontractor_invoice_items(unit_price, quantity)')
+ : Promise.resolve({ data: [] });
+
+ const [{ data: t }, { data: p }, { data: subs }, { data: profiles }, { data: activity }, { data: invs }, { data: exps }, { data: subcontractorPOs }, { data: subcontractorInvoices }, { data: delivs }] = await withTimeout(Promise.all([
tasksQuery,
projectsQuery,
submissionsQuery,
supabase.from('profiles').select('id, role, name, email, company_id, brand_book_rate, avatar_url'),
supabase.from('activity_log').select('id, created_at, actor_id, actor_name, action, task_id, task_title, project_name, project_id').order('created_at', { ascending: false }).limit(isTeam ? 20 : 50),
- supabase.from('companies').select('id, name').order('name'),
- supabase.from('company_members').select('company_id, profile_id'),
invoicesPromise,
expensesPromise,
- supabase.from('deliveries').select('sent_by, sent_at, version_number, submission:submissions!inner(task:tasks!inner(assigned_to, assigned_name))').gte('sent_at', cutoffStr),
+ subcontractorPOsPromise,
+ subcontractorInvoicesPromise,
+ supabase.from('deliveries').select('sent_by, sent_at, version_number, submission:submissions!inner(task_id, task:tasks!inner(assigned_to, assigned_name))').gte('sent_at', cutoffStr),
]), 30000, 'Dashboard load');
if (cancelled) return;
@@ -754,14 +730,14 @@ export default function TeamDashboard() {
const roleById = new Map((profiles || []).map(pr => [pr.id, pr.role]));
const roleByName = new Map((profiles || []).map(pr => [pr.name, pr.role]));
const tasksWithDeadlines = (t || []).map(task => ({ ...task, deadline: getDeadlineSourceSubmission(task, subs)?.deadline || null, assignee_role: roleById.get(task.assigned_to) || null }));
- const subsWithRole = (subs || []).map(sub => ({ ...sub, submitter_role: roleById.get(sub.submitted_by) || null, delivery_sender_role: roleByName.get(sub.delivery?.sent_by) || null }));
+ const subsWithRole = mergeSubmissionDisplayNames(
+ (subs || []).map(sub => ({ ...sub, submitter_role: roleById.get(sub.submitted_by) || null, delivery_sender_role: roleByName.get(sub.delivery?.sent_by) || null })),
+ profiles || []
+ );
setTasks(tasksWithDeadlines);
setProjects(p || []);
setSubmissions(subsWithRole);
- setClientProfiles((profiles || []).filter(pr => pr.role === 'client'));
setAllProfiles(profiles || []);
- setAllCompanies(cos || []);
- setCompanyMemberships(memRows || []);
const scopedActivity = isTeam
? (activity || [])
: (activity || []).filter((entry) => {
@@ -773,16 +749,19 @@ export default function TeamDashboard() {
setTeamInvoices(isTeam ? (invs || []) : []);
setMyInvoices(!isTeam ? (invs || []) : []);
setTeamExpenses(exps || []);
- setPerfDeliveries(delivs || []);
+ setTeamSubcontractorPOs(subcontractorPOs || []);
+ setTeamSubInvoices(subcontractorInvoices || []);
+ const scopedDeliveryTaskIds = new Set(scopedTaskIds || []);
+ const scopedDeliveries = isTeam
+ ? (delivs || [])
+ : (delivs || []).filter(delivery => scopedDeliveryTaskIds.has(delivery.submission?.task_id));
+ setPerfDeliveries(scopedDeliveries);
if (isTeam) {
writePageCache(CACHE_KEY, {
tasks: tasksWithDeadlines,
projects: p || [],
submissions: subsWithRole,
- clientProfiles: (profiles || []).filter(pr => pr.role === 'client'),
- companies: cos || [],
- companyMemberships: memRows || [],
activityLog: activity || [],
teamInvoices: invs || [],
allProfiles: profiles || [],
@@ -797,12 +776,6 @@ export default function TeamDashboard() {
return () => { cancelled = true; };
}, [isTeam, isClient, isExternal, currentUser?.id, currentUser?.company_id, currentUser?.companies, refreshKey]); // eslint-disable-line react-hooks/exhaustive-deps
- const teamHighlights = useMemo(() =>
- isTeam ? buildClientHighlights(allCompanies, projects, tasks, clientProfiles, companyMemberships, teamInvoices)
- .sort((a, b) => b.openTaskCount - a.openTaskCount || b.projectCount - a.projectCount || a.company.name.localeCompare(b.company.name))
- .slice(0, 5) : [],
- [isTeam, allCompanies, projects, tasks, clientProfiles, companyMemberships, teamInvoices]);
-
const activityEvents = useMemo(() =>
(activityLog || []).map(e => ({
time: new Date(e.created_at),
@@ -817,7 +790,7 @@ export default function TeamDashboard() {
})).filter(e => !isNaN(e.time)).slice(0, 10),
[activityLog]);
- if (loading) return
Loading...
;
+ if (loading) return
;
const doneStatuses = ['client_approved', 'invoiced', 'paid'];
const inProgressTasks = tasks.filter(t => t.status === 'in_progress');
@@ -839,7 +812,18 @@ export default function TeamDashboard() {
});
const dashRevenue = teamInvoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total || 0), 0);
const dashOutstanding = teamInvoices.filter(i => i.status === 'sent').reduce((s, i) => s + Number(i.total || 0), 0);
- const dashExpensesTotal = teamExpenses.reduce((s, e) => s + Number(e.amount || 0), 0);
+ const dashOpsExpensesTotal = teamExpenses.reduce((s, e) => s + Number(e.amount || 0), 0);
+ const dashNetReceived = teamInvoices
+ .filter(i => i.status === 'paid')
+ .reduce((s, i) => s + Number(i.total || 0) - Number(i.stripe_fee || 0), 0);
+ const dashPaidSubcontractorPOsTotal = teamSubcontractorPOs
+ .filter(po => po.status === 'paid')
+ .reduce((s, po) => s + Number(po.amount || 0), 0);
+ const dashPaidSubInvoicesTotal = teamSubInvoices
+ .filter(inv => inv.status === 'paid')
+ .reduce((s, inv) => s + (inv.items || []).reduce((a, item) => a + Number(item.unit_price || 0) * Number(item.quantity || 1), 0), 0);
+ const dashSubcontractorExpensesTotal = dashPaidSubcontractorPOsTotal + dashPaidSubInvoicesTotal;
+ const dashExpensesTotal = dashOpsExpensesTotal + dashSubcontractorExpensesTotal;
const revenueByMonth = Array.from({ length: 4 }, (_, i) => {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth() - (3 - i), 1);
@@ -851,7 +835,7 @@ export default function TeamDashboard() {
const myOutstanding = myInvoices.filter(i => i.status !== 'paid').reduce((s, i) => s + Number(i.total || 0), 0);
const card3 = isTeam
- ? { label: 'Net Profit', value: fmtMoney(dashRevenue - dashExpensesTotal), sub: `${fmtMoney(dashExpensesTotal)} expenses`, iconBg: 'rgba(74,222,128,0.15)', iconColor: '#4ade80', iconPath: DASH_ICONS.profit }
+ ? { label: 'Net Profit', value: fmtMoney(dashNetReceived - dashExpensesTotal), sub: `${fmtMoney(dashExpensesTotal)} expenses + sub costs`, iconBg: 'rgba(74,222,128,0.15)', iconColor: '#4ade80', iconPath: DASH_ICONS.profit }
: isClient
? { label: 'Outstanding', value: fmtMoney(myOutstanding), sub: 'invoices due', iconBg: 'rgba(245,165,35,0.15)', iconColor: '#F5A523', iconPath: DASH_ICONS.revenue }
: { label: 'Pending', value: fmtMoney(myOutstanding), sub: 'awaiting payment', iconBg: 'rgba(245,165,35,0.15)', iconColor: '#F5A523', iconPath: DASH_ICONS.revenue };
@@ -875,15 +859,10 @@ export default function TeamDashboard() {
-
-
-
+
+
+
- {isTeam && (
-
-
-
- )}
);
}
diff --git a/src/pages/team/TeamFourgePasswords.jsx b/src/pages/team/TeamFourgePasswords.jsx
index dddbd13..74a4776 100644
--- a/src/pages/team/TeamFourgePasswords.jsx
+++ b/src/pages/team/TeamFourgePasswords.jsx
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import Layout from '../../components/Layout';
import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext';
+import { formatShortDateTime } from '../../lib/dates';
function emptyForm() {
return {
@@ -17,17 +18,6 @@ function sortEntries(entries) {
return [...entries].sort((a, b) => a.service_name.localeCompare(b.service_name, undefined, { sensitivity: 'base' }));
}
-function formatDate(value) {
- if (!value) return 'Unknown';
- return new Date(value).toLocaleString('en-US', {
- year: 'numeric',
- month: 'short',
- day: 'numeric',
- hour: 'numeric',
- minute: '2-digit',
- });
-}
-
function normalizeUrl(value) {
const raw = String(value || '').trim();
if (!raw) return '';
@@ -253,7 +243,7 @@ export default function FourgePasswords() {
}
return (
-
+
Fourge Passwords
@@ -294,10 +284,7 @@ export default function FourgePasswords() {
{loading ? (
Loading passwords...
) : entries.length === 0 ? (
-
-
No passwords yet
-
Add encrypted password entries for internal services and team access.
-
+
No passwords
) : (
{entries.map(entry => (
@@ -316,7 +303,7 @@ export default function FourgePasswords() {
{entry.service_name}
- Updated {formatDate(entry.updated_at || entry.created_at)}
+ Updated {formatShortDateTime(entry.updated_at || entry.created_at, 'Unknown')}
diff --git a/src/pages/team/TeamInvoiceDetail.jsx b/src/pages/team/TeamInvoiceDetail.jsx
index 1b8b9da..21b9635 100644
--- a/src/pages/team/TeamInvoiceDetail.jsx
+++ b/src/pages/team/TeamInvoiceDetail.jsx
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate, useLocation, Link } from 'react-router-dom';
import Layout from '../../components/Layout';
+import PageLoader from '../../components/PageLoader';
import LoadingButton from '../../components/LoadingButton';
import SortTh from '../../components/SortTh';
import StatusBadge from '../../components/StatusBadge';
@@ -9,15 +10,20 @@ import { generateInvoicePDF, generateReceiptPDF } from '../../lib/invoice';
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
import { withTimeout } from '../../lib/withTimeout';
import { useSortable } from '../../hooks/useSortable';
+import { popupOverlayStyle, popupSurfaceStyle } from '../../lib/popupStyles';
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
-export default function InvoiceDetail() {
- const { id } = useParams();
+export function TeamInvoiceDetailPanel({
+ invoiceId,
+ initialInvoice = null,
+ embedded = false,
+ onClose,
+ onInvoiceUpdated,
+ onInvoiceDeleted,
+}) {
const navigate = useNavigate();
- const { state } = useLocation();
-
- const [invoice, setInvoice] = useState(state?.invoice || null);
+ const [invoice, setInvoice] = useState(initialInvoice || null);
const [company, setCompany] = useState(null);
const [companies, setCompanies] = useState([]);
const [items, setItems] = useState([]);
@@ -32,14 +38,14 @@ export default function InvoiceDetail() {
useEffect(() => {
async function load() {
try {
- const { data: inv } = await supabase.from('invoices').select('*').eq('id', id).single();
+ const { data: inv } = await supabase.from('invoices').select('*').eq('id', invoiceId).single();
if (!inv) return;
setInvoice(inv);
const [{ data: co }, { data: companyList }, { data: its }] = await Promise.all([
supabase.from('companies').select('*').eq('id', inv.company_id).single(),
supabase.from('companies').select('*').order('name'),
- supabase.from('invoice_items').select('*').eq('invoice_id', id).order('created_at'),
+ supabase.from('invoice_items').select('*').eq('invoice_id', invoiceId).order('created_at'),
]);
const defaultEmail = inv.invoice_email || await getDefaultInvoiceEmail(inv.company_id, co);
setCompany(co);
@@ -53,23 +59,47 @@ export default function InvoiceDetail() {
}
}
load();
- }, [id]);
+ }, [invoiceId]);
+
+ const syncInvoiceState = (updater) => {
+ setInvoice((current) => {
+ const next = typeof updater === 'function' ? updater(current) : updater;
+ onInvoiceUpdated?.(next);
+ return next;
+ });
+ };
const updateStatus = async (status) => {
setSaving(true);
const updates = { status };
if (status === 'paid' && !invoice.paid_at) updates.paid_at = new Date().toISOString();
if (status !== 'paid') updates.paid_at = null;
- const { error } = await supabase.from('invoices').update(updates).eq('id', id);
+ const { error } = await supabase.from('invoices').update(updates).eq('id', invoiceId);
if (!error) {
- setInvoice(i => ({ ...i, ...updates }));
+ syncInvoiceState(i => ({ ...i, ...updates }));
// Sync task statuses along invoice lifecycle
- const { data: freshItems } = await supabase.from('invoice_items').select('task_id, submission_id').eq('invoice_id', id);
+ 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';
await supabase.from('tasks').update({ status: newTaskStatus }).in('id', taskIds);
}
+ if (status === 'paid') {
+ try {
+ const contactEmail = invoice.invoice_email || await getDefaultInvoiceEmail(invoice.company_id, company);
+ if (contactEmail) {
+ const paidDate = new Date(updates.paid_at || new Date().toISOString()).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
+ await sendEmail('receipt_sent', [contactEmail], {
+ invoiceNumber: invoice.invoice_number,
+ billTo: invoice.bill_to || company?.name,
+ total: `$${Number(invoice.total).toFixed(2)}`,
+ paidDate,
+ });
+ }
+ } catch (emailErr) {
+ console.error('Failed to send paid invoice email:', emailErr);
+ }
+ }
} else {
alert('Failed to update status.');
}
@@ -104,12 +134,12 @@ export default function InvoiceDetail() {
const contactEmail = getEmailRecipient();
if (!contactEmail) throw new Error('Enter an email recipient before sending.');
const { error } = await withTimeout(
- supabase.from('invoices').update({ invoice_email: contactEmail }).eq('id', id),
+ supabase.from('invoices').update({ invoice_email: contactEmail }).eq('id', invoiceId),
12000,
'Saving invoice email'
);
if (error) throw error;
- setInvoice(inv => ({ ...inv, invoice_email: contactEmail }));
+ syncInvoiceState(inv => ({ ...inv, invoice_email: contactEmail }));
return contactEmail;
};
@@ -167,12 +197,12 @@ export default function InvoiceDetail() {
const updates = { status: 'sent' };
const { error } = await withTimeout(
- supabase.from('invoices').update(updates).eq('id', id),
+ supabase.from('invoices').update(updates).eq('id', invoiceId),
12000,
'Updating invoice status'
);
if (error) throw error;
- setInvoice(i => ({ ...i, ...updates }));
+ syncInvoiceState(i => ({ ...i, ...updates }));
alert(`Invoice email sent successfully.${attachmentWarning || ''}`);
} catch (err) {
console.error('Failed to finalize and send invoice:', err);
@@ -202,16 +232,22 @@ export default function InvoiceDetail() {
};
const handleDelete = async () => {
+ if (invoice?.status === 'paid') {
+ alert('Paid invoices cannot be deleted.');
+ return;
+ }
setSaving(true);
try {
- const { data: freshItems } = await supabase.from('invoice_items').select('task_id, submission_id').eq('invoice_id', id);
+ 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) await supabase.from('tasks').update({ invoiced: false, status: 'client_approved' }).in('id', taskIds);
const submissionIds = (freshItems || []).filter(i => i.submission_id).map(i => i.submission_id);
if (submissionIds.length > 0) await supabase.from('submissions').update({ invoiced: false }).in('id', submissionIds);
- const { error } = await supabase.from('invoices').delete().eq('id', id);
+ const { error } = await supabase.from('invoices').delete().eq('id', invoiceId);
if (error) throw error;
- navigate('/invoices');
+ onInvoiceDeleted?.(invoiceId);
+ if (embedded) onClose?.();
+ else navigate('/finances');
} catch {
alert('Failed to delete invoice. Please try again.');
setSaving(false);
@@ -279,8 +315,8 @@ export default function InvoiceDetail() {
await supabase.from('invoices').update({
invoice_date: dateForm.invoice_date,
due_date: dateForm.due_date,
- }).eq('id', id);
- setInvoice(i => ({ ...i, invoice_date: dateForm.invoice_date, due_date: dateForm.due_date }));
+ }).eq('id', invoiceId);
+ syncInvoiceState(i => ({ ...i, invoice_date: dateForm.invoice_date, due_date: dateForm.due_date }));
setEditingDates(false);
setSaving(false);
};
@@ -288,8 +324,8 @@ export default function InvoiceDetail() {
const handleEmailBlur = async () => {
const nextEmail = getEmailRecipient();
if ((invoice.invoice_email || '') === nextEmail) return;
- const { error } = await supabase.from('invoices').update({ invoice_email: nextEmail || null }).eq('id', id);
- if (!error) setInvoice(inv => ({ ...inv, invoice_email: nextEmail || null }));
+ const { error } = await supabase.from('invoices').update({ invoice_email: nextEmail || null }).eq('id', invoiceId);
+ if (!error) syncInvoiceState(inv => ({ ...inv, invoice_email: nextEmail || null }));
};
const handleCompanyChange = async (companyId) => {
@@ -302,19 +338,37 @@ export default function InvoiceDetail() {
company_id: companyId,
bill_to: nextCompany.name,
invoice_email: defaultEmail,
- }).eq('id', id);
+ }).eq('id', invoiceId);
setSaving(false);
if (error) {
alert('Failed to update invoice company. Please try again.');
return;
}
- setInvoice(inv => ({ ...inv, company_id: companyId, bill_to: nextCompany.name, invoice_email: defaultEmail }));
+ syncInvoiceState(inv => ({ ...inv, company_id: companyId, bill_to: nextCompany.name, invoice_email: defaultEmail }));
setCompany(nextCompany);
setEmailRecipient(defaultEmail);
};
- if (loading) return
Loading...
;
- if (!invoice) return
Invoice not found.
;
+ if (loading) {
+ if (embedded) {
+ return (
+
+ );
+ }
+ return
;
+ }
+ if (!invoice) {
+ if (embedded) {
+ return (
+
+ Invoice not found.
+
+ );
+ }
+ return
Invoice not found.
;
+ }
const sortedItems = sort(items, (item, key) => {
if (key === 'type') return item.submission_id ? 'Revision' : 'New';
if (key === 'description') return item.description || '';
@@ -325,19 +379,18 @@ export default function InvoiceDetail() {
});
const isOverdue = invoice.status !== 'paid' && new Date(invoice.due_date) < new Date();
+ const detailContent = (
+ <>
+ {!embedded &&
navigate('/finances')}>← Back to Invoices }
- return (
-
- navigate('/invoices')}>← Back to Invoices
-
-
+
-
{invoice.invoice_number}
-
+
{invoice.invoice_number}
+
{company?.name}
-
+
-
+
Bill To
{invoice.bill_to || company?.name}
@@ -510,9 +563,33 @@ export default function InvoiceDetail() {
>
)}
-
✕
+ {invoice.status !== 'paid' && (
+
✕
+ )}
-
+ >
);
+
+ if (embedded) {
+ return (
+
{ if (!saving && !generating) onClose?.(); }}>
+
e.stopPropagation()}
+ >
+ {detailContent}
+
+
+ );
+ }
+
+ return
{detailContent} ;
+}
+
+export default function InvoiceDetail() {
+ const { id } = useParams();
+ const { state } = useLocation();
+
+ return
;
}
diff --git a/src/pages/team/TeamInvoices.jsx b/src/pages/team/TeamInvoices.jsx
index 28c560c..05e3036 100644
--- a/src/pages/team/TeamInvoices.jsx
+++ b/src/pages/team/TeamInvoices.jsx
@@ -7,6 +7,7 @@ import SortTh from '../../components/SortTh';
import StatusBadge from '../../components/StatusBadge';
import { useSortable } from '../../hooks/useSortable';
import { supabase } from '../../lib/supabase';
+import { useAuth } from '../../context/AuthContext';
import { readPageCache, writePageCache } from '../../lib/pageCache';
import { exportCPAPackage, generateSubcontractorPOPDF, generateInvoicePDF } from '../../lib/invoice';
import { withTimeout } from '../../lib/withTimeout';
@@ -14,6 +15,9 @@ import LoadingButton from '../../components/LoadingButton';
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
import { popupOverlayStyle, popupSurfaceStyle } from '../../lib/popupStyles';
import FileAttachment from '../../components/FileAttachment';
+import { isReviewShadowDescription } from '../../lib/taskVersions';
+import { getRevisionChargeQuantity, isCompletedVersionEligible, isInitialVersionEligible } from '../../lib/invoiceVersionRules';
+import { TeamInvoiceDetailPanel } from './TeamInvoiceDetail';
const CATEGORIES = ['Software', 'Contractor', 'Advertising', 'Office', 'Travel', 'Meals', 'Equipment', 'Other'];
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
@@ -40,12 +44,156 @@ const invoiceStatusBadgeLabel = (status) => {
const RECEIPT_BUCKET = 'expense-receipts';
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 FINANCE_MODAL_INPUT_STYLE = {
+ ...FIELD_INPUT_STYLE,
+ width: '100%',
+ padding: '0 10px',
+ fontSize: 13,
+ color: 'var(--text-primary)',
+ background: 'var(--card-bg-2)',
+ border: '1px solid var(--border)',
+ borderRadius: 6,
+ fontFamily: 'inherit',
+ boxSizing: 'border-box',
+};
+const FINANCE_MODAL_TEXTAREA_STYLE = {
+ ...FINANCE_MODAL_INPUT_STYLE,
+ minHeight: 72,
+ padding: '10px',
+ resize: 'vertical',
+};
+const FINANCE_MODAL_LINE_ITEM_INPUT_STYLE = {
+ ...FINANCE_MODAL_INPUT_STYLE,
+ minHeight: 32,
+ padding: '0 8px',
+};
+const FINANCE_MODAL_NUMBER_INPUT_STYLE = {
+ ...FINANCE_MODAL_LINE_ITEM_INPUT_STYLE,
+ fontVariantNumeric: 'tabular-nums',
+};
+const FINANCE_MODAL_AMOUNT_FIELD_STYLE = {
+ display: 'flex',
+ alignItems: 'center',
+ gap: 8,
+ minHeight: 42,
+ width: '100%',
+ padding: '0 12px',
+ background: 'var(--card-bg-2)',
+ border: '1px solid var(--border)',
+ borderRadius: 6,
+ boxSizing: 'border-box',
+};
+const FINANCE_MODAL_AMOUNT_PREFIX_STYLE = {
+ flexShrink: 0,
+ fontSize: 16,
+ fontWeight: 500,
+ lineHeight: 1,
+ color: 'var(--text-secondary)',
+};
+const FINANCE_MODAL_AMOUNT_INPUT_STYLE = {
+ flex: 1,
+ minWidth: 0,
+ margin: 0,
+ padding: 0,
+ border: 'none',
+ outline: 'none',
+ background: 'transparent',
+ boxShadow: 'none',
+ fontFamily: 'inherit',
+ fontSize: 22,
+ fontWeight: 500,
+ lineHeight: 1.1,
+ textAlign: 'right',
+ color: 'var(--text-primary)',
+ fontVariantNumeric: 'tabular-nums',
+ appearance: 'textfield',
+};
+const FINANCE_MODAL_TOTAL_VALUE_STYLE = {
+ fontSize: 24,
+ fontWeight: 500,
+ lineHeight: 1.1,
+ color: 'var(--accent)',
+ fontVariantNumeric: 'tabular-nums',
+};
+
+function parseSubInvoiceItemVersionNumber(item) {
+ if (Number.isFinite(Number(item?.version_number))) return Number(item.version_number);
+ const match = String(item?.description || '').match(/\bR(\d{2})\b/i);
+ return match ? Number(match[1]) : 0;
+}
+
+function subInvoiceItemVersionLabel(item) {
+ return `R${String(parseSubInvoiceItemVersionNumber(item)).padStart(2, '0')}`;
+}
+
+function subInvoiceItemWorkLabel(item) {
+ return parseSubInvoiceItemVersionNumber(item) > 0 ? 'Revision' : 'New';
+}
+
+function cleanSubInvoiceItemDescription(item) {
+ 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' }) {
+ return (
+
+ $
+
+
+ );
+}
const INV_TODAY = new Date().toISOString().split('T')[0];
const INV_NET30 = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const invNewItem = (description = '', unit_price = '', quantity = 1, task_id = null, submission_id = null) =>
({ id: crypto.randomUUID(), description, unit_price, quantity, task_id, submission_id });
+const invBuildNewItemDescription = (task) => {
+ const projectName = task.project?.name || 'No Project';
+ const taskTitle = task.title || task.service_type || 'Untitled';
+ return `${projectName} • ${taskTitle}`;
+};
+
+const invBuildRevisionItemDescription = (revision) => {
+ const projectName = revision.task?.project?.name || 'No Project';
+ const taskTitle = revision.task?.title || revision.service_type || 'Revision';
+ const versionLabel = 'R' + String(revision.version_number || 0).padStart(2, '0');
+ return `${projectName} • ${taskTitle} • Revision ${versionLabel}`;
+};
+
const blankExpense = () => ({
date: new Date().toISOString().slice(0, 10),
description: '',
@@ -78,6 +226,7 @@ function FinancesChartTooltip({ active, payload, label, year }) {
}
export default function Invoices() {
+ const { currentUser } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const cached = readPageCache('team_invoices');
@@ -126,6 +275,9 @@ export default function Invoices() {
const [subInvoicesLoading, setSubInvoicesLoading] = useState(true);
const [subInvoicesError, setSubInvoicesError] = useState('');
const [markingPaid, setMarkingPaid] = useState('');
+ const [viewingSubInvoice, setViewingSubInvoice] = useState(null);
+ const [viewingSubInvoiceTaskTypeMap, setViewingSubInvoiceTaskTypeMap] = useState({});
+ const [viewingInvoice, setViewingInvoice] = useState(null);
const [expandedSubInvoiceId, setExpandedSubInvoiceId] = useState(null);
// ── New Invoice form ──────────────────────────────────────────────────────
@@ -143,11 +295,30 @@ export default function Invoices() {
const [invSaving, setInvSaving] = useState(false);
const [invLoadingTasks, setInvLoadingTasks] = useState(false);
const invDragItem = useRef(null);
+ const pageLoading = loading || expensesLoading || subcontractorLoading || subInvoicesLoading;
useEffect(() => {
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(() => {
if (!invCompanyId) { setInvUnbilledTasks([]); setInvUnbilledRevisions([]); setInvPriceList([]); setInvItems([invNewItem()]); setInvBillTo(''); setInvEmail(''); setInvRecipients([]); return; }
const co = invCompanies.find(c => c.id === invCompanyId);
@@ -163,12 +334,32 @@ export default function Invoices() {
setInvEmail((users || [])[0]?.email || co?.contact_email || '');
if (projects?.length > 0) {
const pids = projects.map(p => p.id);
- const { data: tasks } = await supabase.from('tasks').select('*, project:projects(name), submissions(service_type, type, version_number)').in('project_id', pids).eq('invoiced', false).eq('status', 'client_approved');
- const tids = (tasks || []).map(t => t.id);
- const { data: revisions } = tids.length > 0 ? await supabase.from('submissions').select('*, task:tasks(id, title, project:projects(name), submissions(service_type, type))').eq('type', 'revision').or('revision_type.eq.client_revision,revision_type.is.null').eq('invoiced', false).in('task_id', tids) : { data: [] };
- setInvUnbilledTasks((tasks || []).map(t => { const ini = (t.submissions || []).find(s => s.type === 'initial') || t.submissions?.[0]; return { ...t, service_type: ini?.service_type || t.title }; }));
+ const { data: companyTasks } = await supabase
+ .from('tasks')
+ .select('*, project:projects(name), submissions(service_type, type, version_number)')
+ .in('project_id', pids)
+ .in('status', ['not_started', 'in_progress', 'on_hold', 'client_review', 'client_approved', 'invoiced', 'paid']);
+ const revisionTaskIds = (companyTasks || []).map(t => t.id).filter(Boolean);
+ const { data: revisions } = revisionTaskIds.length > 0
+ ? await supabase
+ .from('submissions')
+ .select('*, task:tasks(id, title, status, current_version, project:projects(name), submissions(service_type, type))')
+ .eq('type', 'revision')
+ .eq('invoiced', false)
+ .in('task_id', revisionTaskIds)
+ .order('submitted_at', { ascending: false })
+ : { data: [] };
+ const uninvoicedTasks = (companyTasks || []).filter(isInitialVersionEligible);
+ setInvUnbilledTasks(uninvoicedTasks.map(t => {
+ const ini = (t.submissions || []).find(s => s.type === 'initial') || t.submissions?.[0];
+ return { ...t, service_type: ini?.service_type || t.title };
+ }));
const revMap = new Map();
- for (const r of (revisions || [])) { const k = `${r.task_id}:${r.version_number}`; if (!revMap.has(k)) revMap.set(k, r); }
+ for (const r of (revisions || []).filter(r => !isReviewShadowDescription(r.description))) {
+ if (!isCompletedVersionEligible(r.task, r.version_number)) continue;
+ const k = `${r.task_id}:${r.version_number}`;
+ if (!revMap.has(k)) revMap.set(k, r);
+ }
setInvUnbilledRevisions([...revMap.values()]);
} else { setInvUnbilledTasks([]); setInvUnbilledRevisions([]); }
setInvLoadingTasks(false);
@@ -177,18 +368,17 @@ export default function Invoices() {
const invAddTask = (task) => {
const price = invPriceList.find(p => p.service_type === task.service_type && p.price_type === 'new');
- const desc = `${task.project?.name || 'No Project'} • ${task.title || task.service_type || 'Untitled'}`;
+ const desc = invBuildNewItemDescription(task);
setInvItems(prev => prev.length === 1 && !prev[0].description && !prev[0].unit_price ? [invNewItem(desc, price?.price || '', 1, task.id)] : [...prev, invNewItem(desc, price?.price || '', 1, task.id)]);
};
const invAddRevision = (rev) => {
const ini = (rev.task?.submissions || []).find(s => s.type === 'initial') || rev.task?.submissions?.[0];
const svcType = ini?.service_type || rev.service_type || rev.task?.title || 'Revision';
const price = invPriceList.find(p => p.service_type === svcType && p.price_type === 'revision');
- const version = Number(rev.version_number || 0);
- const qty = version >= 2 ? 1 : 1;
- const unitPrice = version >= 2 ? (price?.price || '') : 0;
- const vLabel = 'R' + String(version).padStart(2, '0');
- const desc = `${rev.task?.project?.name || 'No Project'} • ${rev.task?.title || 'Revision'} • Revision ${vLabel}`;
+ const revisionChargeQty = getRevisionChargeQuantity(rev?.version_number);
+ const qty = revisionChargeQty > 0 ? revisionChargeQty : 1;
+ const unitPrice = revisionChargeQty > 0 ? (price?.price || '') : 0;
+ const desc = invBuildRevisionItemDescription(rev);
setInvItems(prev => prev.length === 1 && !prev[0].description && !prev[0].unit_price ? [invNewItem(desc, unitPrice, qty, rev.task_id, rev.id)] : [...prev, invNewItem(desc, unitPrice, qty, rev.task_id, rev.id)]);
};
const invUpdateItem = (id, field, val) => setInvItems(prev => prev.map(it => it.id === id ? { ...it, [field]: val } : it));
@@ -226,14 +416,18 @@ export default function Invoices() {
const payUrl = `https://portal.fourgebranding.com/pay/${encodeURIComponent(invoiceNumber)}`;
const pdfItems = validItems.map(it => ({ description: it.description, quantity: Number(it.quantity) || 1, unit_price: Number(it.unit_price) || 0 }));
let attachments = [];
- try { const pdf = await withTimeout(generateInvoicePDF({ ...invoice, status: 'sent' }, invSelectedCompany, pdfItems, { save: false }), 8000, 'PDF'); attachments = [await withTimeout(blobToEmailAttachment(pdf, `${invoiceNumber}.pdf`), 5000, 'Attachment')]; } catch {}
+ try { const pdf = await withTimeout(generateInvoicePDF({ ...invoice, status: 'sent' }, invSelectedCompany, pdfItems, { save: false }), 8000, 'PDF'); attachments = [await withTimeout(blobToEmailAttachment(pdf, `${invoiceNumber}.pdf`), 5000, 'Attachment')]; } catch { /* PDF attach failed — send email without attachment */ }
await withTimeout(sendEmail('invoice_sent', invEmail.trim(), { invoiceNumber, billTo: invBillTo || invSelectedCompany?.name, total: `$${invTotal.toFixed(2)}`, dueDate, payUrl, notes: invNotes || '' }, attachments), 12000, 'Email');
await supabase.from('invoices').update({ status: 'sent' }).eq('id', invoice.id);
} catch (e) { alert(`Invoice saved as draft — email failed: ${e.message}`); }
}
- setInvoices(prev => [{ ...invoice, company: { name: invSelectedCompany?.name || '' } }, ...prev]);
+ const createdInvoice = {
+ ...invoice,
+ company: invSelectedCompany ? { id: invSelectedCompany.id, name: invSelectedCompany.name } : null,
+ };
+ setInvoices(prev => [createdInvoice, ...prev]);
invClose();
- navigate(`/invoices/${invoice.id}`);
+ setViewingInvoice(createdInvoice);
} catch (e) { alert(`Failed to save invoice: ${e.message || 'Unknown error'}`); }
setInvSaving(false);
};
@@ -409,26 +603,7 @@ export default function Invoices() {
};
const handleSendSubcontractorPO = async (po) => {
- const sentPO = await updateSubcontractorPO(po, { status: 'sent', sent_at: new Date().toISOString() }, 'Failed to send PO');
- if (!sentPO?.profile?.email) return;
- try {
- await sendEmail('subcontractor_po_sent', sentPO.profile.email, {
- poNumber: sentPO.po_number || 'Purchase Order',
- subcontractorName: sentPO.profile?.name || 'there',
- projectName: sentPO.project?.name || 'Subcontractor Work',
- companyName: sentPO.project?.company?.name || 'Fourge Branding',
- amount: `$${Number(sentPO.amount).toFixed(2)}`,
- dueDate: sentPO.due_date ? new Date(sentPO.due_date).toLocaleDateString() : 'Not set',
- terms: sentPO.terms || 'Net 15',
- scope: sentPO.items?.length
- ? sentPO.items.map(item => `${item.description} — $${Number(item.amount).toFixed(2)}`).join('\n')
- : sentPO.description,
- portalUrl: 'https://portal.fourgebranding.com/my-purchase-orders',
- });
- } catch (emailError) {
- console.error('Failed to email subcontractor PO:', emailError);
- alert(`PO was marked sent, but the email failed: ${emailError.message || 'unknown error'}`);
- }
+ await updateSubcontractorPO(po, { status: 'sent', sent_at: new Date().toISOString() }, 'Failed to send PO');
};
const handleReadyToPaySubcontractorPO = (po) => {
@@ -490,6 +665,56 @@ export default function Invoices() {
setMarkingPaid('');
};
+ const handleDownloadSubInvoiceReceipt = async (invoice) => {
+ const items = invoice.items || [];
+ const total = items.reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
+ try {
+ await generateSubcontractorPOPDF({
+ po_number: invoice.invoice_number,
+ status: 'paid',
+ profile: invoice.profile,
+ project: { name: 'Subcontractor Invoice', company: { name: 'Fourge Branding' } },
+ date: invoice.created_at?.split('T')[0],
+ due_date: (invoice.paid_at || new Date().toISOString()).split('T')[0],
+ amount: total,
+ description: 'Payment for completed subcontractor work.',
+ notes: invoice.notes,
+ items: items.map((item, idx) => ({
+ description: item.description,
+ amount: Number(item.unit_price) * Number(item.quantity || 1),
+ sort_order: item.sort_order ?? idx,
+ })),
+ });
+ } catch (err) {
+ alert(`Failed to download receipt: ${err.message || 'unknown error'}`);
+ }
+ };
+
+ const handleDownloadSubInvoice = async (invoice) => {
+ const items = invoice.items || [];
+ const total = items.reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
+ try {
+ await generateSubcontractorPOPDF({
+ po_number: invoice.invoice_number,
+ status: invoice.status || 'draft',
+ profile: invoice.profile,
+ project: { name: 'Subcontractor Invoice', company: { name: 'Fourge Branding' } },
+ date: invoice.created_at?.split('T')[0],
+ due_date: (invoice.paid_at || invoice.submitted_at || invoice.created_at || new Date().toISOString()).split('T')[0],
+ amount: total,
+ description: invoice.status === 'paid' ? 'Payment for completed subcontractor work.' : 'Subcontractor invoice for completed work.',
+ notes: invoice.notes,
+ items: items.map((item, idx) => ({
+ description: item.description,
+ amount: Number(item.unit_price || 0) * Number(item.quantity || 1),
+ sort_order: item.sort_order ?? idx,
+ })),
+ });
+ } catch (err) {
+ alert(`Failed to download invoice: ${err.message || 'unknown error'}`);
+ }
+ };
+
const handleReopenSubcontractorPO = async (po) => {
updateSubcontractorPO(po, { status: 'draft', paid_at: null, cancelled_at: null }, 'Failed to reopen PO');
};
@@ -622,7 +847,7 @@ export default function Invoices() {
return (
-
+
{/* Top row: 60% chart + 40% stat cards */}
{(() => {
@@ -653,7 +878,7 @@ export default function Invoices() {
Revenue Overview
- setChartYear(Number(e.target.value))} style={{ width: 90, borderRadius: 8, fontSize: 11, fontWeight: 500 }}>
+ setExportYear(Number(e.target.value))} style={{ width: 90, borderRadius: 8, fontSize: 11, fontWeight: 500 }}>
{(paidYears.length > 0 ? paidYears : [new Date().getFullYear()]).map(y => {y} )}
@@ -694,7 +919,6 @@ export default function Invoices() {
{ id: 'expenses', label: 'Expenses' },
{ id: 'subcontractors', label: 'Subcontractors' },
{ id: 'invoices', label: 'Invoices' },
- { id: 'legacy', label: 'Legacy' },
].map(t => (
- navigate(`/sub-invoices/${inv.id}`)}>
+ setViewingSubInvoice(inv)}>
{inv.invoice_number || '—'}
@@ -1195,7 +1419,7 @@ export default function Invoices() {
{sorted.map(inv => (
- navigate(`/invoices/${inv.id}`)}>{inv.invoice_number}
+ setViewingInvoice(inv)}>{inv.invoice_number}
inv.company?.id && navigate(`/company/${inv.company.id}`)}>{inv.company?.name || inv.bill_to || '—'}
@@ -1252,24 +1476,22 @@ export default function Invoices() {
})()}
{financeTab === 'legacy' &&
-
+
{[
{ id: 'invoices', label: 'INVOICES' },
{ id: 'expenses', label: 'EXPENSES' },
{ id: 'sub-invoices', label: 'SUBCONTRACTOR INVOICES' },
].map((t, i, arr) => (
-
- setActiveTab(t.id)}
- style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontSize: 13, letterSpacing: 0.5, fontWeight: activeTab === t.id ? 600 : 400, color: activeTab === t.id ? 'var(--text-primary)' : 'var(--text-muted)', fontFamily: 'inherit' }}
- >{t.label}
- {i < arr.length - 1 && | }
-
+ setActiveTab(t.id)}
+ className={`section-tab-btn${activeTab === t.id ? ' is-active' : ''}`}
+ >{t.label}
))}
-
+
{companyNames.length > 0 && activeTab === 'invoices' && (
setFilterCompany(e.target.value)}>
@@ -1310,10 +1532,10 @@ export default function Invoices() {
{loading ? (
Loading...
) : filtered.length === 0 ? (
- No invoices.
+ No invoices
) : (
-
-
+
+
Invoice #
@@ -1330,7 +1552,7 @@ export default function Invoices() {
if (key === 'invoice_date' || key === 'due_date') return new Date(inv[key]).getTime();
return inv[key] || inv.company?.name || '';
}).map(inv => (
- navigate(`/invoices/${inv.id}`)} style={{ cursor: 'pointer' }}>
+ setViewingInvoice(inv)} style={{ cursor: 'pointer' }}>
{inv.invoice_number}
{inv.bill_to || inv.company?.name}
{new Date(inv.invoice_date).toLocaleDateString()}
@@ -1359,17 +1581,17 @@ export default function Invoices() {
{activeTab === 'expenses' && (
-
+
{expensesLoading ? (
Loading...
) : expensesError ? (
{expensesError}
) : filteredExpenses.length === 0 ? (
-
No expenses yet.
+
No expenses
) : (
-
-
+
+
Date
@@ -1431,16 +1653,16 @@ export default function Invoices() {
{activeTab === 'sub-invoices' && (
-
+
{subInvoicesLoading ? (
Loading...
) : subInvoicesError ? (
{subInvoicesError}
) : subInvoices.length === 0 ? (
-
No sub invoices yet.
+
No sub invoices
) : (
-
-
+
+
Invoice #
@@ -1461,7 +1683,7 @@ export default function Invoices() {
const total = (inv.items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
const subInvoiceStatusColor = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
return (
- navigate(`/sub-invoices/${inv.id}`)}>
+ setViewingSubInvoice(inv)}>
{inv.invoice_number}
{inv.profile?.name || 'External'}
@@ -1488,9 +1710,139 @@ export default function Invoices() {
)}
}{/* end legacy wrapper */}
+ {viewingSubInvoice && (() => {
+ const invoice = viewingSubInvoice;
+ const sortedItems = [...(invoice.items || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
+ const total = sortedItems.reduce((s, item) => s + Number(item.unit_price || 0) * Number(item.quantity || 1), 0);
+ const invoiceStatus = invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—';
+ const subInvoiceStatusColor = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
+ return (
+ { if (!markingPaid) setViewingSubInvoice(null); }}>
+
e.stopPropagation()}>
+
+
+
{invoice.invoice_number || 'Subcontractor Invoice'}
+
+ {invoice.profile?.name || 'External'}{invoice.profile?.email ? ` · ${invoice.profile.email}` : ''}
+
+
+
+
+
+
+
+
Submitted
+
{invoice.submitted_at ? new Date(invoice.submitted_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}
+
+
+
Paid
+
{invoice.paid_at ? new Date(invoice.paid_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}
+
+
+
Line Items
+
{sortedItems.length}
+
+
+
Total
+
${total.toFixed(2)}
+
+
+
+
+ {sortedItems.length === 0 ? (
+
No line items
+ ) : (
+
+
+
+
+
+
+
+
+
+
+
+
+ Work
+ R#
+ Description
+ Type
+ Qty
+ Unit Price
+ Amount
+
+
+
+ {sortedItems.map(item => (
+
+ {subInvoiceItemWorkLabel(item)}
+ {subInvoiceItemVersionLabel(item)}
+ {cleanSubInvoiceItemDescription(item)}
+ {viewingSubInvoiceTaskTypeMap[item.task_id] || 'Other'}
+ {item.quantity || 1}
+ ${Number(item.unit_price || 0).toFixed(2)}
+ ${(Number(item.unit_price || 0) * Number(item.quantity || 1)).toFixed(2)}
+
+ ))}
+
+
+ )}
+
+ {invoice.notes && (
+
+
Notes
+
{invoice.notes}
+
+ )}
+
+
+
+ handleDownloadSubInvoice(invoice)}>
+ Download Invoice
+
+ {invoice.status === 'submitted' && (
+ {
+ await handleMarkSubInvoicePaid(invoice);
+ setViewingSubInvoice(current => current?.id === invoice.id ? { ...current, status: 'paid', paid_at: new Date().toISOString() } : current);
+ }} loading={markingPaid === invoice.id} loadingText="Processing…">
+ Mark as Paid
+
+ )}
+ {invoice.status === 'paid' && (
+ handleDownloadSubInvoiceReceipt(invoice)}>
+ Download Receipt
+
+ )}
+ setViewingSubInvoice(null)}>
+ Cancel
+
+
+
+
+ );
+ })()}
+
+ {viewingInvoice && (
+ setViewingInvoice(null)}
+ onInvoiceUpdated={(updatedInvoice) => {
+ setViewingInvoice(updatedInvoice);
+ setInvoices((current) => current.map((invoice) => (invoice.id === updatedInvoice.id ? { ...invoice, ...updatedInvoice } : invoice)));
+ }}
+ onInvoiceDeleted={(deletedId) => {
+ setInvoices((current) => current.filter((invoice) => invoice.id !== deletedId));
+ setViewingInvoice((current) => (current?.id === deletedId ? null : current));
+ }}
+ />
+ )}
+
{viewingExpense && (() => {
- const LABEL = { fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 3 };
- const INPUT = { fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' };
+ const LABEL = { ...FIELD_LABEL_STYLE, marginBottom: 3 };
+ const INPUT = FINANCE_MODAL_INPUT_STYLE;
const isPdf = viewingExpense.receipt_name?.toLowerCase().endsWith('.pdf');
const isDirty = expenseDetailEditing && (
newExpense.amount !== String(viewingExpense.amount ?? '') ||
@@ -1510,9 +1862,13 @@ export default function Invoices() {
Expense Detail
{expenseDetailEditing ? (
-
setNewExpense(p => ({ ...p, amount: e.target.value }))} />
+
setNewExpense(p => ({ ...p, amount: e.target.value }))}
+ />
) : (
-
+
${Number(viewingExpense.amount).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
)}
@@ -1568,7 +1924,7 @@ export default function Invoices() {
)}
{/* Footer: buttons bottom-right */}
-
+
{expenseDetailEditing ? (
<>
{ await saveExpense(); setExpenseDetailEditing(false); setViewingExpense(null); }}>{addingExpense ? 'Saving…' : 'Save'}
@@ -1590,8 +1946,8 @@ export default function Invoices() {
})()}
{showExpenseForm && (() => {
- const LABEL = { fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 3 };
- const INPUT = { fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' };
+ const LABEL = { ...FIELD_LABEL_STYLE, marginBottom: 3 };
+ const INPUT = FINANCE_MODAL_INPUT_STYLE;
return (
e.stopPropagation()}>
@@ -1601,7 +1957,11 @@ export default function Invoices() {
{/* Left: fields */}
New Expense
-
setNewExpense(p => ({ ...p, amount: e.target.value }))} />
+
setNewExpense(p => ({ ...p, amount: e.target.value }))}
+ />
{/* Footer */}
-
+
{addingExpense ? 'Saving…' : 'Save'}
Cancel
@@ -1642,8 +2002,8 @@ export default function Invoices() {
{/* end flex column wrapper */}
{showInvoiceForm && (() => {
- const LABEL = { fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 3 };
- const INPUT = { fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' };
+ const LABEL = { ...FIELD_LABEL_STYLE, marginBottom: 3 };
+ const INPUT = FINANCE_MODAL_INPUT_STYLE;
return (
{ if (!invSaving) invClose(); }}>
e.stopPropagation()}>
@@ -1672,11 +2032,11 @@ export default function Invoices() {
>}
Notes
-
setInvNotes(e.target.value)} placeholder="Payment terms, notes…" />
+ setInvNotes(e.target.value)} placeholder="Payment terms, notes…" />
Total
-
${invTotal.toFixed(2)}
+
${invTotal.toFixed(2)}
@@ -1697,7 +2057,10 @@ export default function Invoices() {
return (
-
{t.project?.name} • {t.title || t.service_type}
+
+
New
+
{t.project?.name} • {t.title || t.service_type}
+
{price ? `$${Number(price.price).toFixed(2)}` : 'No price'}
invAddTask(t)}>{added ? 'Added' : '+ Add'}
@@ -1715,9 +2078,20 @@ export default function Invoices() {
{invUnbilledRevisions.map(r => {
const added = invItems.some(i => i.submission_id === r.id);
+ const revisionQty = getRevisionChargeQuantity(r?.version_number);
+ const initial = (r.task?.submissions || []).find(s => s.type === 'initial') || r.task?.submissions?.[0];
+ const revisionServiceType = initial?.service_type || r.service_type || r.task?.title || 'Revision';
+ const revisionPrice = invPriceList.find(p => p.service_type === revisionServiceType && p.price_type === 'revision');
+ const revisionLabel = revisionQty > 0 ? (revisionPrice ? `$${Number(revisionPrice.price).toFixed(2)}` : 'No price') : 'Free';
return (
-
{r.task?.project?.name} • {r.task?.title} • R{String(r.version_number || 0).padStart(2, '0')}
+
+
+
Revision
+
{r.task?.project?.name} • {r.task?.title} • R{String(r.version_number || 0).padStart(2, '0')}
+
+
{revisionLabel}
+
invAddRevision(r)}>{added ? 'Added' : '+ Add'}
);
@@ -1727,16 +2101,19 @@ export default function Invoices() {
{/* Line items */}
Line Items
-
- {['', 'Description', 'Qty', 'Unit Price', 'Total', ''].map((h, i) =>
2 ? 'right' : 'left' }}>{h}
)}
+
+ {['', 'Type', 'Description', 'Qty', 'Unit Price', 'Total', ''].map((h, i) =>
3 ? 'right' : 'left' }}>{h}
)}
{invItems.map((item, idx) => (
-
e.preventDefault()} onDrop={() => invHandleDrop(idx)} style={{ display: 'grid', gridTemplateColumns: '20px 1fr 60px 100px 90px 28px', gap: 6, alignItems: 'center', marginBottom: 4 }}>
+
e.preventDefault()} onDrop={() => invHandleDrop(idx)} style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 60px 100px 90px 28px', gap: 6, alignItems: 'center', marginBottom: 4 }}>
{ invDragItem.current = idx; }} style={{ cursor: 'grab', color: 'var(--text-muted)', fontSize: 12, textAlign: 'center', userSelect: 'none' }}>⠿
-
invUpdateItem(item.id, 'description', e.target.value)} />
-
invUpdateItem(item.id, 'quantity', e.target.value)} />
-
invUpdateItem(item.id, 'unit_price', e.target.value)} />
-
${((Number(item.quantity) || 0) * (Number(item.unit_price) || 0)).toFixed(2)}
+
+ {item.submission_id ? 'Revision' : 'New'}
+
+
invUpdateItem(item.id, 'description', e.target.value)} />
+
invUpdateItem(item.id, 'quantity', e.target.value)} />
+
invUpdateItem(item.id, 'unit_price', e.target.value)} />
+
${((Number(item.quantity) || 0) * (Number(item.unit_price) || 0)).toFixed(2)}
invRemoveItem(item.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--danger)', fontSize: 14, padding: 2 }}>✕
))}
@@ -1746,7 +2123,7 @@ export default function Invoices() {
{/* Footer */}
-
+
invHandleSave('draft')}>{invSaving ? 'Saving…' : 'Save Draft'}
invHandleSave('sent')} loading={invSaving} loadingText="Sending…">Finalise & Send
Cancel
diff --git a/src/pages/team/TeamReports.jsx b/src/pages/team/TeamReports.jsx
new file mode 100644
index 0000000..3a4ecd0
--- /dev/null
+++ b/src/pages/team/TeamReports.jsx
@@ -0,0 +1,373 @@
+import { useEffect, useMemo, useState } from 'react';
+import jsPDF from 'jspdf';
+import autoTable from 'jspdf-autotable';
+import Layout from '../../components/Layout';
+import SortTh from '../../components/SortTh';
+import StatusBadge from '../../components/StatusBadge';
+import LoadingButton from '../../components/LoadingButton';
+import { useSortable } from '../../hooks/useSortable';
+import { supabase } from '../../lib/supabase';
+import { isReviewShadowDescription } from '../../lib/taskVersions';
+
+function fmtVersion(versionNumber) {
+ return `R${String(Number(versionNumber || 0)).padStart(2, '0')}`;
+}
+
+function billingStatusFromInvoices(items = []) {
+ if (!items.length) return 'not_started';
+ if (items.some((item) => item.invoice?.status === 'paid')) return 'paid';
+ return 'invoiced';
+}
+
+function billingLabel(status) {
+ if (status === 'paid') return 'Paid';
+ if (status === 'invoiced') return 'Invoiced';
+ return 'Not Invoiced';
+}
+
+function versionTypeLabel(versionNumber) {
+ return Number(versionNumber || 0) === 0 ? 'New' : 'Revision';
+}
+
+function versionStateFor(task, versionNumber) {
+ const currentVersion = Number(task?.current_version || 0);
+ const version = Number(versionNumber || 0);
+ if (version < currentVersion) return 'completed';
+ return task?.status || 'not_started';
+}
+
+function rowSort(a, b) {
+ const companyCmp = (a.company_name || '').localeCompare(b.company_name || '');
+ if (companyCmp !== 0) return companyCmp;
+ const projectCmp = (a.project_name || '').localeCompare(b.project_name || '');
+ if (projectCmp !== 0) return projectCmp;
+ const taskCmp = (a.task_title || '').localeCompare(b.task_title || '');
+ if (taskCmp !== 0) return taskCmp;
+ return Number(a.version_number || 0) - Number(b.version_number || 0);
+}
+
+export default function TeamReports() {
+ const [loading, setLoading] = useState(true);
+ const [exporting, setExporting] = useState(false);
+ const [error, setError] = useState('');
+ const [companies, setCompanies] = useState([]);
+ const [projects, setProjects] = useState([]);
+ const [reportRows, setReportRows] = useState([]);
+ const [companyFilter, setCompanyFilter] = useState('all');
+ const [projectFilter, setProjectFilter] = useState('all');
+ const { sortKey, sortDir, toggle, sort } = useSortable('company_name');
+
+ useEffect(() => {
+ let cancelled = false;
+
+ async function load() {
+ setLoading(true);
+ setError('');
+ try {
+ const [
+ { data: taskRows, error: tasksError },
+ { data: submissionRows, error: submissionsError },
+ { data: invoiceItemRows, error: invoiceItemsError },
+ ] = await Promise.all([
+ supabase
+ .from('tasks')
+ .select('id, title, status, current_version, project:projects(id, name, company:companies(id, name))')
+ .order('title'),
+ supabase
+ .from('submissions')
+ .select('id, task_id, type, version_number, submitted_at, invoiced, description')
+ .in('type', ['initial', 'revision'])
+ .order('submitted_at', { ascending: true }),
+ supabase
+ .from('invoice_items')
+ .select('id, task_id, submission_id, invoice:invoices(id, status), submission:submissions(id, task_id, version_number)')
+ ]);
+
+ if (tasksError) throw tasksError;
+ if (submissionsError) throw submissionsError;
+ if (invoiceItemsError) throw invoiceItemsError;
+ if (cancelled) return;
+
+ const taskMap = new Map((taskRows || []).map((task) => [task.id, task]));
+ const companiesMap = new Map();
+ const projectsMap = new Map();
+ (taskRows || []).forEach((task) => {
+ const company = task.project?.company;
+ if (company?.id && !companiesMap.has(company.id)) companiesMap.set(company.id, company);
+ if (task.project?.id && !projectsMap.has(task.project.id)) projectsMap.set(task.project.id, task.project);
+ });
+
+ const versionMap = new Map();
+ for (const submission of (submissionRows || [])) {
+ if (isReviewShadowDescription(submission.description)) continue;
+ const versionNumber = Number(submission.version_number || 0);
+ const key = `${submission.task_id}:${versionNumber}`;
+ if (!versionMap.has(key)) {
+ versionMap.set(key, {
+ task_id: submission.task_id,
+ version_number: versionNumber,
+ submission_ids: [],
+ first_submitted_at: submission.submitted_at || null,
+ });
+ }
+ const entry = versionMap.get(key);
+ entry.submission_ids.push(submission.id);
+ if (!entry.first_submitted_at || (submission.submitted_at && submission.submitted_at < entry.first_submitted_at)) {
+ entry.first_submitted_at = submission.submitted_at;
+ }
+ }
+
+ const invoiceItemGroups = new Map();
+ for (const item of (invoiceItemRows || [])) {
+ const versionNumber = item.submission?.version_number ?? (item.submission_id ? null : 0);
+ const taskId = item.submission?.task_id || item.task_id;
+ if (!taskId || versionNumber === null) continue;
+ const key = `${taskId}:${Number(versionNumber || 0)}`;
+ if (!invoiceItemGroups.has(key)) invoiceItemGroups.set(key, []);
+ invoiceItemGroups.get(key).push(item);
+ }
+
+ const rows = [];
+ for (const task of (taskRows || [])) {
+ const company = task.project?.company;
+ const project = task.project;
+ const taskVersions = [];
+
+ const initialKey = `${task.id}:0`;
+ if (versionMap.has(initialKey) || task) {
+ taskVersions.push(versionMap.get(initialKey) || {
+ task_id: task.id,
+ version_number: 0,
+ submission_ids: [],
+ first_submitted_at: null,
+ });
+ }
+
+ for (const versionEntry of versionMap.values()) {
+ if (versionEntry.task_id !== task.id) continue;
+ if (Number(versionEntry.version_number || 0) === 0) continue;
+ taskVersions.push(versionEntry);
+ }
+
+ taskVersions.sort((a, b) => Number(a.version_number || 0) - Number(b.version_number || 0));
+
+ for (const versionEntry of taskVersions) {
+ const key = `${task.id}:${Number(versionEntry.version_number || 0)}`;
+ const invoiceItems = invoiceItemGroups.get(key) || [];
+ const billingStatus = billingStatusFromInvoices(invoiceItems);
+ rows.push({
+ key,
+ company_id: company?.id || '',
+ company_name: company?.name || '—',
+ project_id: project?.id || '',
+ project_name: project?.name || '—',
+ task_id: task.id,
+ task_title: task.title || 'Untitled',
+ version_number: Number(versionEntry.version_number || 0),
+ version_label: fmtVersion(versionEntry.version_number),
+ version_type: versionTypeLabel(versionEntry.version_number),
+ version_state: versionStateFor(task, versionEntry.version_number),
+ billing_status: billingStatus,
+ billing_label: billingLabel(billingStatus),
+ first_submitted_at: versionEntry.first_submitted_at,
+ });
+ }
+ }
+
+ rows.sort(rowSort);
+
+ setCompanies([...companiesMap.values()].sort((a, b) => (a.name || '').localeCompare(b.name || '')));
+ setProjects([...projectsMap.values()].sort((a, b) => (a.name || '').localeCompare(b.name || '')));
+ setReportRows(rows);
+ } catch (err) {
+ if (!cancelled) setError(err.message || 'Failed to load reports.');
+ } finally {
+ if (!cancelled) setLoading(false);
+ }
+ }
+
+ load();
+ return () => { cancelled = true; };
+ }, []);
+
+ const filteredProjects = useMemo(() => {
+ if (companyFilter === 'all') return projects;
+ return projects.filter((project) => project.company?.id === companyFilter);
+ }, [projects, companyFilter]);
+
+ const visibleRows = useMemo(() => {
+ return reportRows.filter((row) => {
+ if (companyFilter !== 'all' && row.company_id !== companyFilter) return false;
+ if (projectFilter !== 'all' && row.project_id !== projectFilter) return false;
+ return true;
+ });
+ }, [reportRows, companyFilter, projectFilter]);
+
+ const sortedRows = useMemo(() => sort(visibleRows, (row, key) => {
+ if (key === 'company_name') return row.company_name || '';
+ if (key === 'project_name') return row.project_name || '';
+ if (key === 'task_title') return row.task_title || '';
+ if (key === 'version_number') return Number(row.version_number || 0);
+ if (key === 'version_type') return row.version_type || '';
+ if (key === 'version_state') return row.version_state || '';
+ if (key === 'billing_label') return row.billing_label || '';
+ return '';
+ }), [visibleRows, sort]);
+
+ const stats = useMemo(() => ({
+ total: visibleRows.length,
+ notInvoiced: visibleRows.filter((row) => row.billing_status === 'not_started').length,
+ invoiced: visibleRows.filter((row) => row.billing_status === 'invoiced').length,
+ paid: visibleRows.filter((row) => row.billing_status === 'paid').length,
+ }), [visibleRows]);
+
+ const handleExportPdf = async () => {
+ setExporting(true);
+ try {
+ const doc = new jsPDF({ orientation: 'landscape', unit: 'pt', format: 'letter', compress: true });
+ const title = 'Task Billing Report';
+ const companyName = companyFilter === 'all' ? 'All Companies' : (companies.find((company) => company.id === companyFilter)?.name || 'Selected Company');
+ const projectName = projectFilter === 'all' ? 'All Projects' : (projects.find((project) => project.id === projectFilter)?.name || 'Selected Project');
+ const generatedOn = new Date().toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' });
+
+ doc.setFontSize(18);
+ doc.text(title, 40, 40);
+ doc.setFontSize(10);
+ doc.text(`Company: ${companyName}`, 40, 60);
+ doc.text(`Project: ${projectName}`, 240, 60);
+ doc.text(`Generated: ${generatedOn}`, 440, 60);
+
+ autoTable(doc, {
+ startY: 78,
+ head: [['Company', 'Project', 'Task', 'Version', 'Type', 'Version Status', 'Billing']],
+ body: sortedRows.map((row) => [
+ row.company_name,
+ row.project_name,
+ row.task_title,
+ row.version_label,
+ row.version_type,
+ row.version_state === 'client_review' ? 'In Review' : row.version_state === 'client_approved' ? 'Approved' : row.version_state.replace(/_/g, ' '),
+ row.billing_label,
+ ]),
+ styles: {
+ fontSize: 9,
+ cellPadding: 6,
+ lineColor: [225, 225, 225],
+ lineWidth: 0.25,
+ },
+ headStyles: {
+ fillColor: [245, 165, 35],
+ textColor: [17, 17, 17],
+ fontStyle: 'bold',
+ },
+ alternateRowStyles: {
+ fillColor: [250, 250, 250],
+ },
+ margin: { left: 40, right: 40, bottom: 30 },
+ didDrawPage: ({ pageNumber }) => {
+ doc.setFontSize(9);
+ doc.text(`Page ${pageNumber}`, doc.internal.pageSize.width - 70, doc.internal.pageSize.height - 14);
+ },
+ });
+
+ doc.save(`task-billing-report-${new Date().toISOString().slice(0, 10)}.pdf`);
+ } finally {
+ setExporting(false);
+ }
+ };
+
+ return (
+
+
+
+
Company
+
{ setCompanyFilter(event.target.value); setProjectFilter('all'); }}>
+ All Companies
+ {companies.map((company) => (
+ {company.name}
+ ))}
+
+
+
+
Project
+
setProjectFilter(event.target.value)}>
+ All Projects
+ {filteredProjects.map((project) => (
+ {project.name}
+ ))}
+
+
+
+
+ Export PDF
+
+
+
+
+
+
+
+
Not Invoiced
+
{stats.notInvoiced}
+
+
+
Invoiced
+
{stats.invoiced}
+
+
+
+
+
+ {loading ? (
+
Loading report…
+ ) : error ? (
+
{error}
+ ) : sortedRows.length === 0 ? (
+
No report rows for the selected filters.
+ ) : (
+
+
+
+
+
+
+
+
+
+
+
+
+ Company
+ Project
+ Task Name
+ R#
+ Type
+ Version Status
+ Billing
+
+
+
+ {sortedRows.map((row) => (
+
+ {row.company_name}
+ {row.project_name}
+ {row.task_title}
+ {row.version_label}
+
+
+
+
+ ))}
+
+
+ )}
+
+
+ );
+}
diff --git a/src/pages/team/TeamSubInvoiceDetail.jsx b/src/pages/team/TeamSubInvoiceDetail.jsx
index e94e6d9..f5262ce 100644
--- a/src/pages/team/TeamSubInvoiceDetail.jsx
+++ b/src/pages/team/TeamSubInvoiceDetail.jsx
@@ -1,14 +1,13 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout';
-import SortTh from '../../components/SortTh';
-import StatusBadge from '../../components/StatusBadge';
+import PageLoader from '../../components/PageLoader';
+import LoadingButton from '../../components/LoadingButton';
import { supabase } from '../../lib/supabase';
import { generateSubcontractorPOPDF } from '../../lib/invoice';
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
import { useSortable } from '../../hooks/useSortable';
-
-const statusColor = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
+import SubcontractorInvoiceDetailView from '../../components/SubcontractorInvoiceDetailView';
export default function SubInvoiceDetail() {
const { id } = useParams();
@@ -17,6 +16,7 @@ export default function SubInvoiceDetail() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [generating, setGenerating] = useState(false);
+ const [error, setError] = useState('');
const { sortKey, sortDir, toggle, sort } = useSortable('description');
useEffect(() => {
@@ -25,18 +25,24 @@ export default function SubInvoiceDetail() {
.select('*, profile:profiles!subcontractor_invoices_profile_id_fkey(id, name, email), items:subcontractor_invoice_items(*)')
.eq('id', id)
.single()
- .then(({ data }) => {
- setInvoice(data);
+ .then(({ data, error: err }) => {
+ if (err || !data) setError('Invoice not found.');
+ setInvoice(data || null);
setLoading(false);
});
}, [id]);
- const total = (invoice?.items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
+ const total = (invoice?.items || []).reduce((sum, item) => sum + Number(item.unit_price || 0) * Number(item.quantity || 1), 0);
+ const sortedItems = [...(invoice?.items || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
const tableItems = sort(sortedItems, (item, key) => {
+ const typeSort = /\bR(\d{2})\b/i.test(String(item.description || ''))
+ ? Number(String(item.description).match(/\bR(\d{2})\b/i)?.[1] || 0)
+ : item.task_id ? 0 : 999;
+ if (key === 'type') return typeSort;
if (key === 'description') return item.description || '';
if (key === 'quantity') return Number(item.quantity || 0);
if (key === 'unit_price') return Number(item.unit_price || 0);
- if (key === 'amount') return Number(item.unit_price || 0) * Number(item.quantity || 1);
+ if (key === 'line_total') return Number(item.unit_price || 0) * Number(item.quantity || 1);
return '';
});
@@ -58,15 +64,18 @@ export default function SubInvoiceDetail() {
});
const handleMarkPaid = async () => {
+ if (!invoice) return;
setSaving(true);
try {
const paidAt = new Date().toISOString();
- const { error } = await supabase.from('subcontractor_invoices').update({ status: 'paid', paid_at: paidAt }).eq('id', id);
- if (error) throw error;
+ const { error: updateError } = await supabase.from('subcontractor_invoices').update({ status: 'paid', paid_at: paidAt }).eq('id', id);
+ if (updateError) throw updateError;
const updated = { ...invoice, status: 'paid', paid_at: paidAt };
setInvoice(updated);
let pdfBlob = null;
- try { pdfBlob = await generateSubcontractorPOPDF(buildPDFArgs(updated), { output: 'blob' }); } catch {}
+ try {
+ pdfBlob = await generateSubcontractorPOPDF(buildPDFArgs(updated), { output: 'blob' });
+ } catch { /* PDF generation failed — continue without attachment */ }
if (invoice.profile?.email) {
try {
const attachments = pdfBlob ? [await blobToEmailAttachment(pdfBlob, `${invoice.invoice_number}-receipt.pdf`)] : [];
@@ -77,136 +86,90 @@ export default function SubInvoiceDetail() {
paidDate: new Date(paidAt).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }),
}, attachments);
} catch (emailErr) {
- alert(`Marked paid, but email failed: ${emailErr.message || 'unknown error'}`);
+ setError(`Marked paid, but email failed: ${emailErr.message || 'unknown error'}`);
}
}
} catch (err) {
- alert(`Failed to mark as paid: ${err.message}`);
+ setError(`Failed to mark as paid: ${err.message}`);
}
setSaving(false);
};
const handleReceipt = async () => {
+ if (!invoice) return;
setGenerating(true);
try {
await generateSubcontractorPOPDF(buildPDFArgs(invoice));
} catch (err) {
- alert(err.message);
+ setError(err.message);
}
setGenerating(false);
};
const handleDelete = async () => {
+ if (!invoice) return;
if (!window.confirm(`Delete invoice ${invoice.invoice_number}? This cannot be undone.`)) return;
setSaving(true);
- const { error } = await supabase.from('subcontractor_invoices').delete().eq('id', id);
- if (error) { alert('Failed to delete: ' + error.message); setSaving(false); return; }
- navigate('/invoices', { state: { tab: 'sub-invoices' } });
+ const { error: deleteError } = await supabase.from('subcontractor_invoices').delete().eq('id', id);
+ if (deleteError) {
+ setError(`Failed to delete: ${deleteError.message}`);
+ setSaving(false);
+ return;
+ }
+ navigate('/finances', { state: { tab: 'sub-invoices' } });
};
- if (loading) return
Loading...
;
- if (!invoice) return
Invoice not found.
;
+ if (loading) return
;
+ if (!invoice) return
{error || 'Invoice not found.'}
;
- const sortedItems = [...(invoice.items || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
+ const headerActions = (
+ <>
+ {invoice.status === 'submitted' ? (
+
+ Mark as Paid
+
+ ) : null}
+ {invoice.status === 'paid' ? (
+
+ Download Receipt
+
+ ) : null}
+
+ Delete
+
+ >
+ );
return (
- navigate('/invoices', { state: { tab: 'sub-invoices' } })}>
- ← Back to Subcontractor Invoices
-
-
-
-
-
{invoice.invoice_number}
-
- {invoice.profile?.name || 'External'} · {invoice.profile?.email || '—'}
-
-
-
-
-
-
-
-
Invoice Info
-
-
Invoice # {invoice.invoice_number}
-
-
Submitted {invoice.submitted_at ? new Date(invoice.submitted_at).toLocaleDateString() : '—'}
- {invoice.paid_at && (
-
Paid On {new Date(invoice.paid_at).toLocaleDateString()}
- )}
-
-
-
-
Summary
-
-
Line Items {sortedItems.length}
-
-
-
-
-
-
-
Line Items
- {sortedItems.length === 0 ? (
-
No line items.
- ) : (
-
-
-
-
- Description
- Qty
- Unit Price
- Amount
-
-
-
- {tableItems.map(item => (
-
- {item.description}
- {item.quantity}
- ${Number(item.unit_price).toFixed(2)}
- ${(Number(item.unit_price) * Number(item.quantity || 1)).toFixed(2)}
-
- ))}
-
- Total
- ${total.toFixed(2)}
-
-
-
+
+ {invoice.profile?.name || 'External'}
+ {invoice.profile?.email || '—'}
+ >
+ )}
+ rightCardTitle="Invoice Details"
+ rightCardBody={(
+
+
Invoice # {invoice.invoice_number}
+
Status {invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—'}
+
Submitted {invoice.submitted_at ? new Date(invoice.submitted_at).toLocaleDateString() : '—'}
+ {invoice.paid_at ?
Paid On {new Date(invoice.paid_at).toLocaleDateString()}
: null}
+
Line Items {sortedItems.length}
+
)}
- {invoice.notes && (
-
-
Notes
-
{invoice.notes}
-
- )}
-
-
-
-
Actions
-
- {invoice.status === 'submitted' && (
-
- {saving ? 'Processing…' : 'Mark as Paid'}
-
- )}
- {invoice.status === 'paid' && (
-
- {generating ? 'Generating…' : 'Download Receipt'}
-
- )}
-
- ✕
-
-
-
+ sortKey={sortKey}
+ sortDir={sortDir}
+ onSort={toggle}
+ items={tableItems}
+ notes={invoice.notes}
+ total={total}
+ />
);
}
diff --git a/src/pages/team/TeamSubcontractorPODetail.jsx b/src/pages/team/TeamSubcontractorPODetail.jsx
index 09b8807..6719629 100644
--- a/src/pages/team/TeamSubcontractorPODetail.jsx
+++ b/src/pages/team/TeamSubcontractorPODetail.jsx
@@ -1,11 +1,11 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import Layout from '../../components/Layout';
+import PageLoader from '../../components/PageLoader';
import LoadingButton from '../../components/LoadingButton';
import SortTh from '../../components/SortTh';
import StatusBadge from '../../components/StatusBadge';
import { supabase } from '../../lib/supabase';
-import { sendEmail } from '../../lib/email';
import { generateSubcontractorPOPDF } from '../../lib/invoice';
import { useSortable } from '../../hooks/useSortable';
@@ -73,49 +73,14 @@ export default function SubcontractorPODetail() {
return data;
};
- const sendPOEmail = async (sentPO) => {
- if (!sentPO?.profile?.email) {
- alert('This subcontractor has no email on file.');
- return;
- }
- await sendEmail('subcontractor_po_sent', sentPO.profile.email, {
- poNumber: sentPO.po_number || 'Purchase Order',
- subcontractorName: sentPO.profile?.name || 'there',
- projectName: sentPO.project?.name || 'Subcontractor Work',
- companyName: sentPO.project?.company?.name || 'Fourge Branding',
- amount: `$${Number(sentPO.amount).toFixed(2)}`,
- dueDate: sentPO.due_date ? new Date(sentPO.due_date).toLocaleDateString() : 'Not set',
- terms: sentPO.terms || 'Net 15',
- scope: sentPO.items?.length
- ? sentPO.items.map(item => `${item.description} - $${Number(item.amount).toFixed(2)}`).join('\n')
- : sentPO.description,
- portalUrl: 'https://portal.fourgebranding.com/my-purchase-orders',
- });
- };
-
const handleFinalizeSend = async () => {
if (!window.confirm(`Finalize and send ${po.po_number || 'this PO'} to ${po.profile?.email || 'the subcontractor'}?`)) return;
const sentPO = await updatePO({ status: 'sent', sent_at: new Date().toISOString() }, 'Failed to finalize PO');
if (!sentPO) return;
- try {
- await sendPOEmail(sentPO);
- alert('PO email sent successfully.');
- } catch (error) {
- alert(`PO was finalized, but the email failed: ${error.message || 'unknown error'}`);
- }
};
const handleResend = async () => {
- if (!window.confirm(`Resend ${po.po_number || 'this PO'} to ${po.profile?.email || 'the subcontractor'}?`)) return;
- setSaving(true);
- try {
- await sendPOEmail(po);
- alert('PO email sent successfully.');
- } catch (error) {
- alert(`Failed to send PO: ${error.message || 'unknown error'}`);
- } finally {
- setSaving(false);
- }
+ alert('PO emails are disabled.');
};
const handleReadyToPay = () => updatePO({ status: 'ready_to_pay' }, 'Failed to mark ready to pay');
@@ -136,7 +101,7 @@ export default function SubcontractorPODetail() {
setSaving(false);
return;
}
- navigate('/invoices', { state: { tab: 'subcontractor-po' } });
+ navigate('/finances', { state: { tab: 'subcontractor-po' } });
};
const handleDownload = async () => {
@@ -149,7 +114,7 @@ export default function SubcontractorPODetail() {
}
};
- if (loading) return
Loading...
;
+ if (loading) return
;
if (!po) return
Purchase order not found.
;
const sortedItems = (po.items || []).slice().sort((a, b) => Number(a.sort_order || 0) - Number(b.sort_order || 0));
@@ -163,7 +128,7 @@ export default function SubcontractorPODetail() {
return (
- navigate('/invoices', { state: { tab: 'subcontractor-po' } })}>← Back to Subcontractor PO
+ navigate('/finances', { state: { tab: 'subcontractor-po' } })}>← Back to Subcontractor PO
diff --git a/supabase/functions/fbq-backfill/index.ts b/supabase/functions/fbq-backfill/index.ts
deleted file mode 100644
index e641ace..0000000
--- a/supabase/functions/fbq-backfill/index.ts
+++ /dev/null
@@ -1,168 +0,0 @@
-import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
-
-const FBQ_URL = Deno.env.get('FBQ_URL') ?? '';
-const FBQ_TOKEN = Deno.env.get('FBQ_TOKEN') ?? '';
-const SUPABASE_URL = Deno.env.get('SUPABASE_URL') ?? '';
-const SUPABASE_SERVICE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '';
-const SOURCE = 'srv';
-const fbqH = { Authorization: `Bearer ${FBQ_TOKEN}` };
-
-function safe(v: string) {
- return String(v||'').trim().replace(/[\\/:*?"<>|#%{}^~[\]`]+/g,'-').replace(/\s+/g,' ').replace(/^-+|-+$/g,'');
-}
-
-// 200 = created new, 409 = already existed
-async function mkdir(path: string): Promise
{
- const p = path.endsWith('/') ? path : path + '/';
- const r = await fetch(`${FBQ_URL}/api/resources?source=${SOURCE}&path=${encodeURIComponent(p)}&isDir=true`, {
- method: 'POST', headers: fbqH,
- });
- return r.status === 200;
-}
-
-async function upload(path: string, data: ArrayBuffer, ct: string) {
- await fetch(`${FBQ_URL}/api/resources?source=${SOURCE}&path=${encodeURIComponent(path)}`, {
- method: 'POST', headers: { ...fbqH, 'Content-Type': ct }, body: data,
- });
-}
-
-function mime(name: string) {
- const e = name.split('.').pop()?.toLowerCase() ?? '';
- const m: Record = { pdf:'application/pdf', jpg:'image/jpeg', jpeg:'image/jpeg', png:'image/png', gif:'image/gif', webp:'image/webp', svg:'image/svg+xml', ai:'application/postscript', eps:'application/postscript', zip:'application/zip', mp4:'video/mp4', mov:'video/quicktime' };
- return m[e] ?? 'application/octet-stream';
-}
-
-async function listDir(path: string, log?: string[]): Promise<{ name: string }[] | null> {
- const r = await fetch(`${FBQ_URL}/api/resources?source=${SOURCE}&path=${encodeURIComponent(path)}`, { headers: fbqH });
- if (log) log.push(`listDir ${path} → ${r.status}`);
- if (!r.ok) return null;
- const data = await r.json().catch(() => null);
- const entries = Array.isArray(data?.entries) ? data.entries
- : [...(data?.folders ?? []), ...(data?.files ?? [])];
- return entries;
-}
-
-async function fbDelete(path: string): Promise {
- const r = await fetch(`${FBQ_URL}/api/resources?source=${SOURCE}&path=${encodeURIComponent(path)}`, { method: 'DELETE', headers: fbqH });
- return r.ok;
-}
-
-async function fbRename(fromPath: string, toPath: string): Promise {
- const r = await fetch(`${FBQ_URL}/api/resources?source=${SOURCE}`, {
- method: 'PATCH',
- headers: { ...fbqH, 'Content-Type': 'application/json' },
- body: JSON.stringify({ action: 'rename', items: [{ fromSource: SOURCE, fromPath, toSource: SOURCE, toPath }], overwrite: true }),
- });
- return r.ok;
-}
-
-async function migrateOldBook(base: string, log: string[]): Promise {
- const src = `${base}/Old Book`;
- const dst = `${base}/Old Books`;
- const srcItems = await listDir(src, log);
- if (srcItems === null) return;
- if (srcItems.length === 0) {
- // Empty — just delete it
- const ok = await fbDelete(src);
- log.push(`${ok ? 'migrated' : 'migrate-fail'} (delete empty): ${src}`);
- } else {
- // Has contents — move each item then delete
- for (const item of srcItems) {
- await fbRename(`${src}/${item.name}`, `${dst}/${item.name}`);
- }
- const ok = await fbDelete(src);
- log.push(`${ok ? 'migrated' : 'migrate-fail'} (merge+delete): ${src} → ${dst}`);
- }
-}
-
-async function chunkAll(items: T[], fn: (item: T) => Promise, size = 8) {
- for (let i = 0; i < items.length; i += size) {
- await Promise.all(items.slice(i, i + size).map(fn));
- }
-}
-
-Deno.serve(async () => {
- const sb = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY);
- const log: string[] = [];
- let created = 0, skipped = 0, fileCount = 0, errors = 0;
-
- // fetch all data upfront in parallel
- const [usersRes, companiesRes, tasksRes] = await Promise.all([
- sb.from('profiles').select('name,role').in('role',['team','external']),
- sb.from('companies').select('name'),
- sb.from('tasks').select('id,title,projects!project_id(name,companies!company_id(name))'),
- ]);
-
- log.push(`db: ${usersRes.data?.length} users, ${companiesRes.data?.length} companies, ${tasksRes.data?.length ?? 0} tasks`);
- if (tasksRes.error) log.push(`task err: ${tasksRes.error.message}`);
-
- // 1. user folders in parallel
- await mkdir('/Team');
- await chunkAll(usersRes.data ?? [], async (u) => {
- if (!u.name) return;
- const isNew = await mkdir(`/Team/${safe(u.name)}`);
- if (isNew) { log.push(`+ /Team/${safe(u.name)}`); created++; } else skipped++;
- });
-
- // 2. company folders in parallel
- await mkdir('/Clients');
- await chunkAll(companiesRes.data ?? [], async (c) => {
- if (!c.name) return;
- const isNew = await mkdir(`/Clients/${safe(c.name)}`);
- if (isNew) { log.push(`+ /Clients/${safe(c.name)}`); created++; } else skipped++;
- });
-
- // 3. tasks — project structure + task folder in parallel, then files sequentially for new ones
- const newTasks: { task: any; base: string; co: string; pr: string }[] = [];
-
- await chunkAll(tasksRes.data ?? [], async (task: any) => {
- const coName = task?.projects?.companies?.name;
- const prjName = task?.projects?.name;
- if (!coName || !prjName || !task.title) return;
-
- const co = safe(coName);
- const pr = safe(prjName);
- const tk = safe(task.title);
- const base = `/Clients/${co}/Projects/${pr}/${tk}`;
-
- // ensure project structure (idempotent)
- await Promise.all([
- mkdir(`/Clients/${co}/Projects`),
- mkdir(`/Clients/${co}/Projects/${pr}`),
- mkdir(`/Clients/${co}/Projects/${pr}/00 Project Files`),
- ]);
-
- const taskIsNew = await mkdir(base);
- await Promise.all([mkdir(`${base}/Request Info`), mkdir(`${base}/Old Books`)]);
- if (log.filter(l => l.startsWith('listDir')).length < 3) await migrateOldBook(base, log);
-
- if (taskIsNew) { newTasks.push({ task, base, co, pr }); created++; }
- else skipped++;
- }, 6);
-
- log.push(`new task folders: ${newTasks.length}`);
- newTasks.forEach(({ base }) => log.push(`+ ${base}`));
-
- // 4. upload files for newly created task folders (sequential to avoid memory spikes)
- for (const { task, base } of newTasks) {
- const { data: subs } = await sb.from('submissions').select('id,version_number').eq('task_id', task.id);
- for (const sub of subs ?? []) {
- const { data: subFiles } = await sb.from('submission_files').select('name,storage_path').eq('submission_id', sub.id);
- if (!subFiles?.length) continue;
- const rev = `R${String(sub.version_number).padStart(2,'0')}`;
- await mkdir(`${base}/Request Info/${rev}`);
- for (const f of subFiles) {
- try {
- const { data: blob, error } = await sb.storage.from('submissions').download(f.storage_path);
- if (error || !blob) { errors++; continue; }
- await upload(`${base}/Request Info/${rev}/${f.name}`, await blob.arrayBuffer(), mime(f.name));
- fileCount++;
- } catch { errors++; }
- }
- }
- }
-
- return new Response(JSON.stringify({ created, skipped, files: fileCount, errors, log }, null, 2), {
- headers: { 'Content-Type': 'application/json' },
- });
-});
diff --git a/supabase/functions/fbq-proxy/index.ts b/supabase/functions/fbq-proxy/index.ts
deleted file mode 100644
index 826281c..0000000
--- a/supabase/functions/fbq-proxy/index.ts
+++ /dev/null
@@ -1,288 +0,0 @@
-import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
-
-const FBQ_URL = Deno.env.get('FBQ_URL') ?? '';
-const FBQ_TOKEN = Deno.env.get('FBQ_TOKEN') ?? '';
-const SUPABASE_URL = Deno.env.get('SUPABASE_URL') ?? '';
-const SUPABASE_SERVICE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '';
-const SOURCE = 'srv';
-
-function cors(origin: string) {
- return {
- 'Access-Control-Allow-Origin': origin,
- 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
- 'Access-Control-Allow-Headers': 'Authorization, Content-Type, X-Operation, X-Path, X-Files',
- };
-}
-
-function json(body: unknown, status = 200, origin = '*') {
- return new Response(JSON.stringify(body), {
- status,
- headers: { ...cors(origin), 'Content-Type': 'application/json' },
- });
-}
-
-function normalizePath(path: string) {
- const raw = String(path || '/').trim();
- const parts = raw.split('/').filter(Boolean);
- return '/' + parts.join('/');
-}
-
-function safeSegment(value: string, fallback = '') {
- const cleaned = String(value || '')
- .trim()
- .replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
- .replace(/\s+/g, ' ')
- .replace(/^-+|-+$/g, '');
- return cleaned || fallback;
-}
-
-type ExternalProject = {
- id: string;
- name: string;
- companyName: string;
- virtualName: string;
- virtualRoot: string;
- realRoot: string;
-};
-
-type Access =
- | { role: 'team'; roots: string[] }
- | { role: 'client'; roots: string[] }
- | { role: 'external'; roots: string[]; projects: ExternalProject[] };
-
-function pathAllowed(path: string, access: Access): boolean {
- if (access.roots.includes('*')) return true;
- const p = normalizePath(path).replace(/\/+$/, '') || '/';
- if (access.role === 'client' && p === '/Clients' && access.roots.some(r => r.startsWith('/Clients/'))) return true;
- if (access.role === 'external' && p === '/') return true;
- return access.roots.some(r => p === r || p.startsWith(r.replace(/\/$/, '') + '/'));
-}
-
-async function getExternalProjects(sb: ReturnType, userId: string): Promise {
- const { data, error } = await sb
- .from('project_members')
- .select('project:projects(id, name, company:companies(name))')
- .eq('profile_id', userId);
-
- if (error) throw error;
-
- const seen = new Map();
- return (data ?? [])
- .map((row: any) => {
- const projectId = row?.project?.id;
- const projectName = row?.project?.name;
- const companyName = row?.project?.company?.name;
- if (!projectId || !projectName || !companyName) return null;
- const baseName = safeSegment(projectName, projectId);
- const count = seen.get(baseName) ?? 0;
- seen.set(baseName, count + 1);
- const virtualName = count === 0 ? baseName : `${baseName} (${safeSegment(companyName, projectId)})`;
- const companyFolder = safeSegment(companyName, companyName);
- const projectFolder = safeSegment(projectName, projectId);
- return {
- id: projectId,
- name: projectName,
- companyName,
- virtualName,
- virtualRoot: `/${virtualName}`,
- realRoot: `/Clients/${companyFolder}/Projects/${projectFolder}`,
- };
- })
- .filter(Boolean)
- .sort((a, b) => a!.virtualName.localeCompare(b!.virtualName)) as ExternalProject[];
-}
-
-async function getUserAccess(userId: string): Promise {
- const sb = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY);
- const { data: profile } = await sb.from('profiles').select('role, company_id').eq('id', userId).single();
- if (!profile) return null;
- if (profile.role === 'team') return { role: 'team', roots: ['*'] };
- if (profile.role === 'external') {
- const projects = await getExternalProjects(sb, userId);
- return {
- role: 'external',
- roots: ['/Team', ...projects.map(project => project.virtualRoot)],
- projects,
- };
- }
- if (profile.role === 'client') {
- const roots: string[] = [];
- if (profile.company_id) {
- const { data: co } = await sb.from('companies').select('name').eq('id', profile.company_id).single();
- if (co?.name) roots.push(`/Clients/${co.name}`);
- }
- const { data: memberships } = await sb.from('company_members').select('company:companies(name)').eq('profile_id', userId);
- (memberships ?? []).forEach((m: any) => {
- const n = m?.company?.name;
- if (n && !roots.includes(`/Clients/${n}`)) roots.push(`/Clients/${n}`);
- });
- return roots.length ? { role: 'client', roots } : null;
- }
- return null;
-}
-
-async function listVirtualClientsRoot(roots: string[]) {
- const folders = roots
- .filter((root) => root.startsWith('/Clients/'))
- .map((root) => root.replace(/\/+$/, ''))
- .map((root) => {
- const name = root.split('/').filter(Boolean).pop() ?? '';
- return {
- name,
- filename: name,
- path: root,
- type: 'directory',
- isDir: true,
- size: 0,
- };
- })
- .sort((a, b) => a.name.localeCompare(b.name));
-
- return { folders, files: [] };
-}
-
-function listVirtualExternalRoot(projects: ExternalProject[]) {
- return {
- folders: [
- { name: 'Team', filename: 'Team', path: '/Team', type: 'directory', isDir: true, size: 0 },
- ...projects.map((project) => ({
- name: project.virtualName,
- filename: project.virtualName,
- path: project.virtualRoot,
- type: 'directory',
- isDir: true,
- size: 0,
- })),
- ],
- files: [],
- };
-}
-
-function resolvePath(path: string, access: Access) {
- const normalized = normalizePath(path);
- if (access.role !== 'external') return normalized;
- if (normalized === '/') return normalized;
- if (normalized === '/Team' || normalized.startsWith('/Team/')) {
- return normalized.replace(/^\/Team(?=\/|$)/, '/Team');
- }
- const project = access.projects.find((entry) => normalized === entry.virtualRoot || normalized.startsWith(entry.virtualRoot + '/'));
- if (!project) return normalized;
- const suffix = normalized.slice(project.virtualRoot.length);
- return `${project.realRoot}${suffix}` || project.realRoot;
-}
-
-const fbqHeaders = { Authorization: `Bearer ${FBQ_TOKEN}` };
-
-Deno.serve(async (req) => {
- const origin = req.headers.get('origin') ?? '*';
- if (req.method === 'OPTIONS') return new Response(null, { headers: cors(origin) });
-
- const authHeader = req.headers.get('authorization') ?? '';
- if (!authHeader.startsWith('Bearer ')) return json({ error: 'Unauthorized' }, 401, origin);
-
- const sb = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY);
- const { data: { user }, error } = await sb.auth.getUser(authHeader.slice(7));
- if (error || !user) return json({ error: 'Unauthorized' }, 401, origin);
-
- const access = await getUserAccess(user.id);
- if (!access) return json({ error: 'Forbidden' }, 403, origin);
-
- const op = req.headers.get('x-operation') ?? '';
- const path = req.headers.get('x-path') ?? '/';
-
- if (!pathAllowed(path, access)) return json({ error: 'Forbidden' }, 403, origin);
- const resolvedPath = resolvePath(path, access);
-
- if (op === 'list') {
- if (access.role === 'client' && path.replace(/\/+$/, '') === '/Clients' && !access.roots.includes('*')) {
- return json(await listVirtualClientsRoot(access.roots), 200, origin);
- }
- if (access.role === 'external' && normalizePath(path) === '/') {
- return json(listVirtualExternalRoot(access.projects), 200, origin);
- }
- const res = await fetch(`${FBQ_URL}/api/resources?source=${SOURCE}&path=${encodeURIComponent(resolvedPath)}`, { headers: fbqHeaders });
- const body = await res.text();
- return new Response(body, { status: res.status, headers: { ...cors(origin), 'Content-Type': 'application/json' } });
- }
-
- if (op === 'archive') {
- const files = JSON.parse(req.headers.get('x-files') ?? '[]') as string[];
- const params = new URLSearchParams({ source: SOURCE });
- files.map(f => resolvePath(f, access)).forEach(f => params.append('files[]', f));
- const res = await fetch(`${FBQ_URL}/api/archive?${params.toString()}`, { headers: fbqHeaders });
- const cd = `attachment; filename="download.zip"`;
- return new Response(res.body, { status: res.status, headers: { ...cors(origin), 'Content-Type': 'application/zip', 'Content-Disposition': cd } });
- }
-
- if (op === 'download') {
- const rawUrl = `${FBQ_URL}/api/raw?source=${SOURCE}&path=${encodeURIComponent(resolvedPath)}`;
- const rawRes = await fetch(rawUrl, { headers: fbqHeaders });
-
- if (rawRes.ok) {
- const ct = rawRes.headers.get('content-type') ?? 'application/octet-stream';
- const cd = rawRes.headers.get('content-disposition') ?? `attachment; filename="${resolvedPath.split('/').pop()}"`;
- return new Response(rawRes.body, { status: rawRes.status, headers: { ...cors(origin), 'Content-Type': ct, 'Content-Disposition': cd } });
- }
-
- const rawErrorText = await rawRes.text();
- const shouldFallbackToResourcesDownload = rawRes.status === 400 && rawErrorText.toLowerCase().includes('no files specified');
- if (!shouldFallbackToResourcesDownload) {
- return new Response(rawErrorText, { status: rawRes.status, headers: { ...cors(origin), 'Content-Type': 'application/json' } });
- }
-
- const directDownloadUrl = `${FBQ_URL}/api/resources/download?source=${SOURCE}&file=${encodeURIComponent(resolvedPath)}`;
- const directRes = await fetch(directDownloadUrl, { headers: fbqHeaders });
- const directCt = directRes.headers.get('content-type') ?? 'application/octet-stream';
- const directCd = directRes.headers.get('content-disposition') ?? `attachment; filename="${resolvedPath.split('/').pop() ?? 'download'}"`;
- return new Response(directRes.body, {
- status: directRes.status,
- headers: { ...cors(origin), 'Content-Type': directCt, 'Content-Disposition': directCd },
- });
- }
-
- if (op === 'upload') {
- const body = await req.arrayBuffer();
- const ct = req.headers.get('content-type') ?? '';
- const res = await fetch(`${FBQ_URL}/api/resources?source=${SOURCE}&path=${encodeURIComponent(resolvedPath)}`, {
- method: 'POST', headers: { ...fbqHeaders, 'Content-Type': ct }, body,
- });
- const text = await res.text();
- return new Response(text, { status: res.status, headers: { ...cors(origin), 'Content-Type': 'application/json' } });
- }
-
- if (op === 'delete') {
- const files = JSON.parse(req.headers.get('x-files') ?? '[]') as string[];
- await Promise.all(files.map(f =>
- fetch(`${FBQ_URL}/api/resources?source=${SOURCE}&path=${encodeURIComponent(resolvePath(f, access))}`, { method: 'DELETE', headers: fbqHeaders })
- ));
- return json({ ok: true }, 200, origin);
- }
-
- if (op === 'copy' || op === 'move') {
- const srcPaths = JSON.parse(req.headers.get('x-files') ?? '[]') as string[];
- const destDir = resolvedPath.replace(/\/+$/, '');
- const items = srcPaths.map(src => {
- const resolved = resolvePath(src, access);
- const name = resolved.split('/').filter(Boolean).pop() ?? '';
- return { fromSource: SOURCE, fromPath: resolved, toSource: SOURCE, toPath: `${destDir}/${name}` };
- });
- const res = await fetch(`${FBQ_URL}/api/resources?source=${SOURCE}`, {
- method: 'PATCH',
- headers: { ...fbqHeaders, 'Content-Type': 'application/json' },
- body: JSON.stringify({ action: op, items, overwrite: false }),
- });
- const text = await res.text();
- return new Response(text, { status: res.status, headers: { ...cors(origin), 'Content-Type': 'application/json' } });
- }
-
- if (op === 'mkdir') {
- const dirPath = resolvedPath.endsWith('/') ? resolvedPath : resolvedPath + '/';
- const res = await fetch(`${FBQ_URL}/api/resources?source=${SOURCE}&path=${encodeURIComponent(dirPath)}&isDir=true`, {
- method: 'POST', headers: fbqHeaders,
- });
- const text = await res.text();
- return new Response(text, { status: res.status, headers: { ...cors(origin), 'Content-Type': 'application/json' } });
- }
-
- return json({ error: 'Unknown operation' }, 400, origin);
-});
diff --git a/supabase/functions/send-email/index.ts b/supabase/functions/send-email/index.ts
index efa76a1..685cdad 100644
--- a/supabase/functions/send-email/index.ts
+++ b/supabase/functions/send-email/index.ts
@@ -4,6 +4,7 @@ import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
const RESEND_API_KEY = Deno.env.get('RESEND_API_KEY');
const SUPABASE_URL = Deno.env.get('SUPABASE_URL');
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY');
+const SUPABASE_WEBHOOK_SECRET = Deno.env.get('SUPABASE_WEBHOOK_SECRET');
const FROM = 'Fourge Branding ';
const TEAM_EMAILS = [
'krao@fourgebranding.com',
@@ -12,7 +13,7 @@ const TEAM_EMAILS = [
'twebb@fourgebranding.com',
];
-const ALLOWED_TYPES = ['new_request', 'sent_to_client', 'revision_submitted', 'client_approved', 'invoice_sent', 'receipt_sent', 'subcontractor_po_sent', 'subcontractor_invoice_submitted'] as const;
+const ALLOWED_TYPES = ['new_request', 'sent_to_client', 'revision_submitted', 'client_approved', 'invoice_sent', 'receipt_sent', 'subcontractor_po_sent', 'subcontractor_invoice_submitted', 'task_status_update'] as const;
type EmailType = typeof ALLOWED_TYPES[number];
// Types that only team members may trigger
@@ -53,6 +54,8 @@ const optStr = (val: unknown, max = 500): string => {
return val.trim();
};
+const optBool = (val: unknown): boolean => val === true;
+
/** Basic UUID format check. */
const isUuid = (v: unknown): v is string =>
typeof v === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v);
@@ -102,26 +105,32 @@ serve(async (req) => {
// ── 1. Authentication ────────────────────────────────────────────────────
const authHeader = req.headers.get('Authorization') ?? '';
const accessToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
- if (!accessToken) {
+ const webhookSecret = req.headers.get('x-webhook-secret') ?? '';
+ const isInternalWebhook = !!SUPABASE_WEBHOOK_SECRET && webhookSecret === SUPABASE_WEBHOOK_SECRET;
+ if (!accessToken && !isInternalWebhook) {
return respond({ error: 'Unauthorized' }, 401);
}
// Use service role client to validate the token — works with ES256 JWTs
const adminClient = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
- const { data: userData, error: authError } = await adminClient.auth.getUser(accessToken);
- if (authError || !userData?.user) {
- return respond({ error: `Auth failed: ${authError?.message ?? 'no user'}` }, 401);
+ let userData: { user?: { id: string } } | null = null;
+ if (!isInternalWebhook) {
+ const authResult = await adminClient.auth.getUser(accessToken);
+ userData = authResult.data;
+ if (authResult.error || !userData?.user) {
+ return respond({ error: `Auth failed: ${authResult.error?.message ?? 'no user'}` }, 401);
+ }
}
// ── 2. Role lookup ───────────────────────────────────────────────────────
- const { data: profile } = await adminClient
- .from('profiles')
- .select('role')
- .eq('id', userData.user.id)
- .single();
-
- const role: string = profile?.role ?? '';
+ const role: string = isInternalWebhook
+ ? 'team'
+ : ((await adminClient
+ .from('profiles')
+ .select('role')
+ .eq('id', userData?.user?.id)
+ .single()).data?.role ?? '');
if (!['team', 'client', 'external'].includes(role)) {
return respond({ error: 'Forbidden' }, 403);
}
@@ -163,17 +172,55 @@ serve(async (req) => {
subject = `New Request: ${esc(serviceType)} — ${esc(clientName)}`;
html = `
- New Request Received
- From: ${esc(clientName)} (${esc(clientEmail)})
- Company: ${esc(company) || '—'}
- Service: ${esc(serviceType)}
- Project: ${esc(projectName)}
- ${deadline ? `Deadline: ${esc(deadline)}
` : ''}
-
- Description:
- ${escMultiline(description)}
-
- View Job
+
+
+
+
+
+
New Request Received
+
A new request has been created in the portal.
+
+