04e0911e9f
Bug fixes: - TeamInvoices: add useAuth import/currentUser (invoice-create crash) - TeamInvoices: setChartYear -> setExportYear (year-dropdown crash) - TeamDashboard: drop redundant setState-in-effect (cascading renders) - Companies: delete dead _UnusedClientCompanies (illegal hook calls) - annotate intentional empty PDF-fallback catches Load speed: - preconnect/dns-prefetch to Supabase origin - lazy-load heic-to in Converters: page chunk 2737KB -> 9KB - split recharts into its own 'charts' vendor chunk Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
302 lines
11 KiB
JavaScript
302 lines
11 KiB
JavaScript
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);
|