Fix file sharing load speed and move error; misc updates
- Remove recursive directory size calculations (single Seafile API call per list) - Remove 'Used in this location' usage display - Fix move using v2 per-type endpoints instead of broken batch endpoint - Send entry type from frontend for correct move routing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,839 @@
|
||||
import JSZip from 'jszip';
|
||||
import { supabase } from './supabase';
|
||||
import { getBrandBookStoragePaths, getCompanyLogoStoragePath } from './deleteHelpers';
|
||||
|
||||
const ARCHIVE_VERSION = 1;
|
||||
const FILE_BATCH_SIZE = 100;
|
||||
const ROW_BATCH_SIZE = 100;
|
||||
|
||||
function sanitizeFilename(value) {
|
||||
return (value || 'archive').replace(/[^a-z0-9._ -]/gi, '_').trim();
|
||||
}
|
||||
|
||||
function reportProgress(onProgress, message) {
|
||||
if (typeof onProgress === 'function') onProgress(message);
|
||||
}
|
||||
|
||||
function triggerDownload(blob, filename) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
function chunk(list, size) {
|
||||
const chunks = [];
|
||||
for (let i = 0; i < list.length; i += size) chunks.push(list.slice(i, i + size));
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function uniqueValues(values) {
|
||||
return [...new Set((values || []).filter(Boolean))];
|
||||
}
|
||||
|
||||
async function selectByValues(table, column, values, select = '*') {
|
||||
const results = [];
|
||||
for (const batch of chunk(values, FILE_BATCH_SIZE)) {
|
||||
const { data, error } = await supabase.from(table).select(select).in(column, batch);
|
||||
if (error) throw error;
|
||||
results.push(...(data || []));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async function insertRows(table, rows) {
|
||||
if (!rows?.length) return;
|
||||
for (const batch of chunk(rows, ROW_BATCH_SIZE)) {
|
||||
const { error } = await supabase.from(table).insert(batch);
|
||||
if (error) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStorageBlob(bucket, path) {
|
||||
const { data, error } = await supabase.storage.from(bucket).download(path);
|
||||
if (error) throw new Error(`Failed to download ${bucket}/${path}: ${error.message}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function fillZipWithFiles(zip, fileRefs, onProgress) {
|
||||
if (!fileRefs.length) {
|
||||
reportProgress(onProgress, 'No files to download');
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < fileRefs.length; i += 1) {
|
||||
const fileRef = fileRefs[i];
|
||||
reportProgress(onProgress, `Downloading files ${i + 1}/${fileRefs.length}`);
|
||||
const blob = await fetchStorageBlob(fileRef.bucket, fileRef.path);
|
||||
zip.file(fileRef.zipPath, blob);
|
||||
}
|
||||
}
|
||||
|
||||
function buildBrandBookFileRefs(books) {
|
||||
return (books || []).flatMap(book =>
|
||||
getBrandBookStoragePaths(book).map(path => ({
|
||||
bucket: 'brand-books',
|
||||
path,
|
||||
zipPath: `storage/brand-books/${path}`,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
function buildSubmissionFileRefs(submissionFiles) {
|
||||
return (submissionFiles || [])
|
||||
.filter(file => file.storage_path)
|
||||
.map(file => ({
|
||||
bucket: 'submissions',
|
||||
path: file.storage_path,
|
||||
zipPath: `storage/submissions/${file.storage_path}`,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildDeliveryFileRefs(deliveryFiles) {
|
||||
return (deliveryFiles || [])
|
||||
.filter(file => file.storage_path)
|
||||
.map(file => ({
|
||||
bucket: 'deliveries',
|
||||
path: file.storage_path,
|
||||
zipPath: `storage/deliveries/${file.storage_path}`,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildCompanyLogoFileRefs(company, brandBooks) {
|
||||
const urls = uniqueValues([
|
||||
company?.client_logo_url,
|
||||
...(brandBooks || []).map(book => book.client_logo_url),
|
||||
]);
|
||||
|
||||
return urls
|
||||
.map(url => getCompanyLogoStoragePath(url))
|
||||
.filter(Boolean)
|
||||
.map(path => ({
|
||||
bucket: 'company-logos',
|
||||
path,
|
||||
zipPath: `storage/company-logos/${path}`,
|
||||
}));
|
||||
}
|
||||
|
||||
function dedupeFileRefs(fileRefs) {
|
||||
const seen = new Set();
|
||||
return fileRefs.filter(ref => {
|
||||
const key = `${ref.bucket}:${ref.path}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async function collectCompanySnapshot(companyId) {
|
||||
const [
|
||||
{ data: company, error: companyError },
|
||||
{ data: companyPrices, error: pricesError },
|
||||
{ data: projects, error: projectsError },
|
||||
{ data: invoices, error: invoicesError },
|
||||
{ data: brandBooks, error: brandBooksError },
|
||||
{ data: companyProfiles, error: profilesError },
|
||||
] = await Promise.all([
|
||||
supabase.from('companies').select('*').eq('id', companyId).single(),
|
||||
supabase.from('company_prices').select('*').eq('company_id', companyId),
|
||||
supabase.from('projects').select('*').eq('company_id', companyId),
|
||||
supabase.from('invoices').select('*').eq('company_id', companyId),
|
||||
supabase.from('brand_books').select('*').eq('client_id', companyId),
|
||||
supabase.from('profiles').select('id, name, email, role, company_id').eq('company_id', companyId),
|
||||
]);
|
||||
|
||||
if (companyError) throw companyError;
|
||||
if (pricesError) throw pricesError;
|
||||
if (projectsError) throw projectsError;
|
||||
if (invoicesError) throw invoicesError;
|
||||
if (brandBooksError) throw brandBooksError;
|
||||
if (profilesError) throw profilesError;
|
||||
if (!company) throw new Error('Company not found.');
|
||||
|
||||
const projectIds = (projects || []).map(project => project.id);
|
||||
const invoiceIds = (invoices || []).map(invoice => invoice.id);
|
||||
const [tasks, projectMembers, invoiceItems] = await Promise.all([
|
||||
projectIds.length ? selectByValues('tasks', 'project_id', projectIds) : Promise.resolve([]),
|
||||
projectIds.length ? selectByValues('project_members', 'project_id', projectIds) : Promise.resolve([]),
|
||||
invoiceIds.length ? selectByValues('invoice_items', 'invoice_id', invoiceIds) : Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const taskIds = tasks.map(task => task.id);
|
||||
const submissions = taskIds.length ? await selectByValues('submissions', 'task_id', taskIds) : [];
|
||||
const submissionIds = submissions.map(submission => submission.id);
|
||||
const [submissionFiles, deliveries] = await Promise.all([
|
||||
submissionIds.length ? selectByValues('submission_files', 'submission_id', submissionIds) : Promise.resolve([]),
|
||||
submissionIds.length ? selectByValues('deliveries', 'submission_id', submissionIds) : Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const deliveryIds = deliveries.map(delivery => delivery.id);
|
||||
const deliveryFiles = deliveryIds.length ? await selectByValues('delivery_files', 'delivery_id', deliveryIds) : [];
|
||||
|
||||
const referencedProfileIds = uniqueValues([
|
||||
...companyProfiles.map(profile => profile.id),
|
||||
...projectMembers.map(member => member.profile_id),
|
||||
...tasks.map(task => task.assigned_to),
|
||||
...submissions.map(submission => submission.submitted_by),
|
||||
...invoices.map(invoice => invoice.created_by),
|
||||
]);
|
||||
|
||||
const referencedProfiles = referencedProfileIds.length
|
||||
? await selectByValues('profiles', 'id', referencedProfileIds, 'id, name, email, role, company_id')
|
||||
: [];
|
||||
|
||||
const fileRefs = dedupeFileRefs([
|
||||
...buildSubmissionFileRefs(submissionFiles),
|
||||
...buildDeliveryFileRefs(deliveryFiles),
|
||||
...buildBrandBookFileRefs(brandBooks),
|
||||
...buildCompanyLogoFileRefs(company, brandBooks),
|
||||
]);
|
||||
|
||||
return {
|
||||
company,
|
||||
companyPrices,
|
||||
projects,
|
||||
tasks,
|
||||
submissions,
|
||||
submissionFiles,
|
||||
deliveries,
|
||||
deliveryFiles,
|
||||
invoices,
|
||||
invoiceItems,
|
||||
brandBooks,
|
||||
projectMembers,
|
||||
companyProfiles,
|
||||
referencedProfiles,
|
||||
fileRefs,
|
||||
};
|
||||
}
|
||||
|
||||
async function collectTaskSnapshot(taskIds) {
|
||||
const tasks = taskIds.length ? await selectByValues('tasks', 'id', taskIds) : [];
|
||||
const projectIds = uniqueValues(tasks.map(task => task.project_id));
|
||||
const projects = projectIds.length ? await selectByValues('projects', 'id', projectIds) : [];
|
||||
const companyIds = uniqueValues(projects.map(project => project.company_id));
|
||||
const companies = companyIds.length ? await selectByValues('companies', 'id', companyIds) : [];
|
||||
|
||||
const submissions = taskIds.length ? await selectByValues('submissions', 'task_id', taskIds) : [];
|
||||
const submissionIds = submissions.map(submission => submission.id);
|
||||
const [submissionFiles, deliveries] = await Promise.all([
|
||||
submissionIds.length ? selectByValues('submission_files', 'submission_id', submissionIds) : Promise.resolve([]),
|
||||
submissionIds.length ? selectByValues('deliveries', 'submission_id', submissionIds) : Promise.resolve([]),
|
||||
]);
|
||||
const deliveryIds = deliveries.map(delivery => delivery.id);
|
||||
const deliveryFiles = deliveryIds.length ? await selectByValues('delivery_files', 'delivery_id', deliveryIds) : [];
|
||||
const invoiceItems = submissionIds.length
|
||||
? (await selectByValues('invoice_items', 'submission_id', submissionIds)).filter(item => taskIds.includes(item.task_id))
|
||||
: [];
|
||||
|
||||
const referencedProfileIds = uniqueValues([
|
||||
...tasks.map(task => task.assigned_to),
|
||||
...submissions.map(submission => submission.submitted_by),
|
||||
]);
|
||||
const referencedProfiles = referencedProfileIds.length
|
||||
? await selectByValues('profiles', 'id', referencedProfileIds, 'id, name, email, role, company_id')
|
||||
: [];
|
||||
|
||||
const fileRefs = dedupeFileRefs([
|
||||
...buildSubmissionFileRefs(submissionFiles),
|
||||
...buildDeliveryFileRefs(deliveryFiles),
|
||||
]);
|
||||
|
||||
return {
|
||||
tasks,
|
||||
projects,
|
||||
companies,
|
||||
submissions,
|
||||
submissionFiles,
|
||||
deliveries,
|
||||
deliveryFiles,
|
||||
invoiceItems,
|
||||
referencedProfiles,
|
||||
fileRefs,
|
||||
};
|
||||
}
|
||||
|
||||
async function collectBrandBookSnapshot(bookId) {
|
||||
const { data: brandBook, error } = await supabase.from('brand_books').select('*').eq('id', bookId).single();
|
||||
if (error) throw error;
|
||||
if (!brandBook) throw new Error('Brand book not found.');
|
||||
|
||||
const company = brandBook.client_id
|
||||
? (await supabase.from('companies').select('id, name').eq('id', brandBook.client_id).single()).data
|
||||
: null;
|
||||
|
||||
const fileRefs = dedupeFileRefs([
|
||||
...buildBrandBookFileRefs([brandBook]),
|
||||
]);
|
||||
|
||||
return {
|
||||
brandBook,
|
||||
company,
|
||||
fileRefs,
|
||||
};
|
||||
}
|
||||
|
||||
async function collectBrandBooksSnapshot(bookIds) {
|
||||
const brandBooks = bookIds.length ? await selectByValues('brand_books', 'id', bookIds) : [];
|
||||
if (!brandBooks.length) throw new Error('No brand books found.');
|
||||
|
||||
const companyIds = uniqueValues(brandBooks.map(book => book.client_id));
|
||||
const companies = companyIds.length ? await selectByValues('companies', 'id', companyIds, 'id, name') : [];
|
||||
|
||||
const fileRefs = dedupeFileRefs([
|
||||
...buildBrandBookFileRefs(brandBooks),
|
||||
]);
|
||||
|
||||
return {
|
||||
brandBooks,
|
||||
companies,
|
||||
fileRefs,
|
||||
};
|
||||
}
|
||||
|
||||
function buildArchiveManifest(snapshot) {
|
||||
return {
|
||||
archive_version: ARCHIVE_VERSION,
|
||||
archived_at: new Date().toISOString(),
|
||||
scope: 'company',
|
||||
company_id: snapshot.company.id,
|
||||
company_name: snapshot.company.name,
|
||||
data: {
|
||||
company: snapshot.company,
|
||||
company_prices: snapshot.companyPrices,
|
||||
projects: snapshot.projects,
|
||||
tasks: snapshot.tasks,
|
||||
submissions: snapshot.submissions,
|
||||
submission_files: snapshot.submissionFiles,
|
||||
deliveries: snapshot.deliveries,
|
||||
delivery_files: snapshot.deliveryFiles,
|
||||
invoices: snapshot.invoices,
|
||||
invoice_items: snapshot.invoiceItems,
|
||||
brand_books: snapshot.brandBooks,
|
||||
project_members: snapshot.projectMembers,
|
||||
company_profiles: snapshot.companyProfiles,
|
||||
referenced_profiles: snapshot.referencedProfiles,
|
||||
},
|
||||
storage_files: snapshot.fileRefs,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCompaniesArchiveManifest(snapshot) {
|
||||
return {
|
||||
archive_version: ARCHIVE_VERSION,
|
||||
archived_at: new Date().toISOString(),
|
||||
scope: 'companies',
|
||||
company_ids: snapshot.companies.map(company => company.id),
|
||||
data: {
|
||||
companies: snapshot.companies,
|
||||
company_prices: snapshot.companyPrices,
|
||||
projects: snapshot.projects,
|
||||
tasks: snapshot.tasks,
|
||||
submissions: snapshot.submissions,
|
||||
submission_files: snapshot.submissionFiles,
|
||||
deliveries: snapshot.deliveries,
|
||||
delivery_files: snapshot.deliveryFiles,
|
||||
invoices: snapshot.invoices,
|
||||
invoice_items: snapshot.invoiceItems,
|
||||
brand_books: snapshot.brandBooks,
|
||||
project_members: snapshot.projectMembers,
|
||||
company_profiles: snapshot.companyProfiles,
|
||||
referenced_profiles: snapshot.referencedProfiles,
|
||||
},
|
||||
storage_files: snapshot.fileRefs,
|
||||
};
|
||||
}
|
||||
|
||||
export async function archiveCompanyToLocalZip(companyId, options = {}) {
|
||||
const { onProgress } = options;
|
||||
reportProgress(onProgress, 'Collecting company data');
|
||||
const snapshot = await collectCompanySnapshot(companyId);
|
||||
const manifest = buildArchiveManifest(snapshot);
|
||||
const zip = new JSZip();
|
||||
|
||||
zip.file('manifest.json', JSON.stringify(manifest, null, 2));
|
||||
await fillZipWithFiles(zip, snapshot.fileRefs, onProgress);
|
||||
|
||||
reportProgress(onProgress, 'Packaging archive');
|
||||
const blob = await zip.generateAsync({ type: 'blob' });
|
||||
const filename = `${sanitizeFilename(snapshot.company.name)}_${snapshot.company.id}_archive.zip`;
|
||||
reportProgress(onProgress, 'Starting download');
|
||||
triggerDownload(blob, filename);
|
||||
|
||||
return {
|
||||
filename,
|
||||
companyName: snapshot.company.name,
|
||||
fileCount: snapshot.fileRefs.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function archiveCompaniesToLocalZip(companyIds, options = {}) {
|
||||
const { onProgress } = options;
|
||||
reportProgress(onProgress, 'Collecting company data');
|
||||
const companies = companyIds.length ? await selectByValues('companies', 'id', companyIds) : [];
|
||||
if (!companies.length) throw new Error('No companies found.');
|
||||
|
||||
const snapshots = await Promise.all(companies.map(company => collectCompanySnapshot(company.id)));
|
||||
const merged = {
|
||||
companies: snapshots.map(snapshot => snapshot.company),
|
||||
companyPrices: snapshots.flatMap(snapshot => snapshot.companyPrices),
|
||||
projects: snapshots.flatMap(snapshot => snapshot.projects),
|
||||
tasks: snapshots.flatMap(snapshot => snapshot.tasks),
|
||||
submissions: snapshots.flatMap(snapshot => snapshot.submissions),
|
||||
submissionFiles: snapshots.flatMap(snapshot => snapshot.submissionFiles),
|
||||
deliveries: snapshots.flatMap(snapshot => snapshot.deliveries),
|
||||
deliveryFiles: snapshots.flatMap(snapshot => snapshot.deliveryFiles),
|
||||
invoices: snapshots.flatMap(snapshot => snapshot.invoices),
|
||||
invoiceItems: snapshots.flatMap(snapshot => snapshot.invoiceItems),
|
||||
brandBooks: snapshots.flatMap(snapshot => snapshot.brandBooks),
|
||||
projectMembers: snapshots.flatMap(snapshot => snapshot.projectMembers),
|
||||
companyProfiles: snapshots.flatMap(snapshot => snapshot.companyProfiles),
|
||||
referencedProfiles: snapshots.flatMap(snapshot => snapshot.referencedProfiles),
|
||||
fileRefs: dedupeFileRefs(snapshots.flatMap(snapshot => snapshot.fileRefs)),
|
||||
};
|
||||
|
||||
const zip = new JSZip();
|
||||
const manifest = buildCompaniesArchiveManifest(merged);
|
||||
zip.file('manifest.json', JSON.stringify(manifest, null, 2));
|
||||
|
||||
await fillZipWithFiles(zip, merged.fileRefs, onProgress);
|
||||
reportProgress(onProgress, 'Packaging archive');
|
||||
const blob = await zip.generateAsync({ type: 'blob' });
|
||||
const filename = `companies_${merged.companies.length}_${new Date().toISOString().slice(0, 10)}.zip`;
|
||||
reportProgress(onProgress, 'Starting download');
|
||||
triggerDownload(blob, filename);
|
||||
|
||||
return {
|
||||
filename,
|
||||
companyCount: merged.companies.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function archiveCompletedJobsToLocalZip(taskIds, options = {}) {
|
||||
const { onProgress } = options;
|
||||
reportProgress(onProgress, 'Collecting completed jobs');
|
||||
const snapshot = await collectTaskSnapshot(taskIds);
|
||||
if (!snapshot.tasks.length) throw new Error('No completed jobs found to archive.');
|
||||
|
||||
const zip = new JSZip();
|
||||
const manifest = {
|
||||
archive_version: ARCHIVE_VERSION,
|
||||
archived_at: new Date().toISOString(),
|
||||
scope: 'completed_tasks',
|
||||
task_count: snapshot.tasks.length,
|
||||
company_refs: snapshot.companies,
|
||||
project_refs: snapshot.projects,
|
||||
data: {
|
||||
tasks: snapshot.tasks,
|
||||
submissions: snapshot.submissions,
|
||||
submission_files: snapshot.submissionFiles,
|
||||
deliveries: snapshot.deliveries,
|
||||
delivery_files: snapshot.deliveryFiles,
|
||||
invoice_items: snapshot.invoiceItems,
|
||||
referenced_profiles: snapshot.referencedProfiles,
|
||||
},
|
||||
storage_files: snapshot.fileRefs,
|
||||
};
|
||||
|
||||
zip.file('manifest.json', JSON.stringify(manifest, null, 2));
|
||||
await fillZipWithFiles(zip, snapshot.fileRefs, onProgress);
|
||||
reportProgress(onProgress, 'Packaging archive');
|
||||
const blob = await zip.generateAsync({ type: 'blob' });
|
||||
const filename = `completed_jobs_${snapshot.tasks.length}_${new Date().toISOString().slice(0, 10)}.zip`;
|
||||
reportProgress(onProgress, 'Starting download');
|
||||
triggerDownload(blob, filename);
|
||||
|
||||
return {
|
||||
filename,
|
||||
taskCount: snapshot.tasks.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function archiveBrandBookToLocalZip(bookId, options = {}) {
|
||||
const { onProgress } = options;
|
||||
reportProgress(onProgress, 'Collecting brand book');
|
||||
const snapshot = await collectBrandBookSnapshot(bookId);
|
||||
const zip = new JSZip();
|
||||
const manifest = {
|
||||
archive_version: ARCHIVE_VERSION,
|
||||
archived_at: new Date().toISOString(),
|
||||
scope: 'brand_book',
|
||||
brand_book_id: snapshot.brandBook.id,
|
||||
client_ref: snapshot.company,
|
||||
data: {
|
||||
brand_book: snapshot.brandBook,
|
||||
},
|
||||
storage_files: snapshot.fileRefs,
|
||||
};
|
||||
|
||||
zip.file('manifest.json', JSON.stringify(manifest, null, 2));
|
||||
await fillZipWithFiles(zip, snapshot.fileRefs, onProgress);
|
||||
reportProgress(onProgress, 'Packaging archive');
|
||||
const blob = await zip.generateAsync({ type: 'blob' });
|
||||
const filename = `${sanitizeFilename(snapshot.brandBook.client_name || 'brand_book')}_${snapshot.brandBook.id}_brand_book.zip`;
|
||||
reportProgress(onProgress, 'Starting download');
|
||||
triggerDownload(blob, filename);
|
||||
|
||||
return {
|
||||
filename,
|
||||
bookId: snapshot.brandBook.id,
|
||||
};
|
||||
}
|
||||
|
||||
export async function archiveBrandBooksToLocalZip(bookIds, options = {}) {
|
||||
const { onProgress } = options;
|
||||
reportProgress(onProgress, 'Collecting brand books');
|
||||
const snapshot = await collectBrandBooksSnapshot(bookIds);
|
||||
const zip = new JSZip();
|
||||
const manifest = {
|
||||
archive_version: ARCHIVE_VERSION,
|
||||
archived_at: new Date().toISOString(),
|
||||
scope: 'brand_books',
|
||||
brand_book_ids: snapshot.brandBooks.map(book => book.id),
|
||||
company_refs: snapshot.companies,
|
||||
data: {
|
||||
brand_books: snapshot.brandBooks,
|
||||
},
|
||||
storage_files: snapshot.fileRefs,
|
||||
};
|
||||
|
||||
zip.file('manifest.json', JSON.stringify(manifest, null, 2));
|
||||
await fillZipWithFiles(zip, snapshot.fileRefs, onProgress);
|
||||
reportProgress(onProgress, 'Packaging archive');
|
||||
const blob = await zip.generateAsync({ type: 'blob' });
|
||||
const filename = `brand_books_${snapshot.brandBooks.length}_${new Date().toISOString().slice(0, 10)}.zip`;
|
||||
reportProgress(onProgress, 'Starting download');
|
||||
triggerDownload(blob, filename);
|
||||
|
||||
return {
|
||||
filename,
|
||||
bookCount: snapshot.brandBooks.length,
|
||||
};
|
||||
}
|
||||
|
||||
async function assertNoRestoreConflicts(manifest) {
|
||||
const companyRows = manifest.data.companies || (manifest.data.company ? [manifest.data.company] : []);
|
||||
const checks = [
|
||||
['companies', companyRows.map(row => row.id)],
|
||||
['company_prices', (manifest.data.company_prices || []).map(row => row.id)],
|
||||
['projects', (manifest.data.projects || []).map(row => row.id)],
|
||||
['tasks', (manifest.data.tasks || []).map(row => row.id)],
|
||||
['submissions', (manifest.data.submissions || []).map(row => row.id)],
|
||||
['submission_files', (manifest.data.submission_files || []).map(row => row.id)],
|
||||
['deliveries', (manifest.data.deliveries || []).map(row => row.id)],
|
||||
['delivery_files', (manifest.data.delivery_files || []).map(row => row.id)],
|
||||
['invoices', (manifest.data.invoices || []).map(row => row.id)],
|
||||
['invoice_items', (manifest.data.invoice_items || []).map(row => row.id)],
|
||||
['brand_books', (manifest.data.brand_books || []).map(row => row.id)],
|
||||
['project_members', (manifest.data.project_members || []).map(row => row.id)],
|
||||
];
|
||||
|
||||
for (const [table, ids] of checks) {
|
||||
const values = uniqueValues(ids);
|
||||
if (!values.length) continue;
|
||||
const existing = await selectByValues(table, 'id', values, 'id');
|
||||
if (existing.length) {
|
||||
throw new Error(`Restore blocked: ${table} already contains archived IDs.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readAnyArchiveManifest(file, onProgress) {
|
||||
reportProgress(onProgress, 'Reading archive');
|
||||
const zip = await JSZip.loadAsync(file);
|
||||
const manifestFile = zip.file('manifest.json');
|
||||
if (!manifestFile) throw new Error('Invalid archive: manifest.json is missing.');
|
||||
|
||||
reportProgress(onProgress, 'Reading manifest');
|
||||
const manifest = JSON.parse(await manifestFile.async('string'));
|
||||
if (manifest.archive_version !== ARCHIVE_VERSION) throw new Error('Unsupported archive version.');
|
||||
|
||||
return { zip, manifest };
|
||||
}
|
||||
|
||||
async function uploadArchiveFiles(zip, fileRefs, onProgress) {
|
||||
const refs = fileRefs || [];
|
||||
if (!refs.length) {
|
||||
reportProgress(onProgress, 'No files to restore');
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < refs.length; i += 1) {
|
||||
const ref = refs[i];
|
||||
const zipFile = zip.file(ref.zipPath);
|
||||
if (!zipFile) throw new Error(`Archive is missing ${ref.zipPath}.`);
|
||||
|
||||
reportProgress(onProgress, `Uploading files ${i + 1}/${refs.length}`);
|
||||
const blob = await zipFile.async('blob');
|
||||
const { error } = await supabase.storage.from(ref.bucket).upload(ref.path, blob, {
|
||||
upsert: true,
|
||||
contentType: blob.type || undefined,
|
||||
});
|
||||
if (error) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function getMissingProfileSummary(existingProfileIds, manifest) {
|
||||
const hasProfile = id => !id || existingProfileIds.has(id);
|
||||
|
||||
return {
|
||||
companyProfiles: (manifest.data.company_profiles || []).filter(profile => !existingProfileIds.has(profile.id)).length,
|
||||
taskAssignments: (manifest.data.tasks || []).filter(task => task.assigned_to && !hasProfile(task.assigned_to)).length,
|
||||
submissions: (manifest.data.submissions || []).filter(submission => submission.submitted_by && !hasProfile(submission.submitted_by)).length,
|
||||
invoices: (manifest.data.invoices || []).filter(invoice => invoice.created_by && !hasProfile(invoice.created_by)).length,
|
||||
projectMembers: (manifest.data.project_members || []).filter(member => member.profile_id && !hasProfile(member.profile_id)).length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function restoreCompanyArchive(file, options = {}) {
|
||||
const { onProgress } = options;
|
||||
const { zip, manifest } = await readAnyArchiveManifest(file, onProgress);
|
||||
if (!['company', 'companies'].includes(manifest.scope)) {
|
||||
throw new Error('This archive is not a company archive.');
|
||||
}
|
||||
|
||||
const normalizedManifest = manifest.scope === 'company'
|
||||
? manifest
|
||||
: {
|
||||
...manifest,
|
||||
data: {
|
||||
company: null,
|
||||
company_prices: manifest.data.company_prices || [],
|
||||
projects: manifest.data.projects || [],
|
||||
tasks: manifest.data.tasks || [],
|
||||
submissions: manifest.data.submissions || [],
|
||||
submission_files: manifest.data.submission_files || [],
|
||||
deliveries: manifest.data.deliveries || [],
|
||||
delivery_files: manifest.data.delivery_files || [],
|
||||
invoices: manifest.data.invoices || [],
|
||||
invoice_items: manifest.data.invoice_items || [],
|
||||
brand_books: manifest.data.brand_books || [],
|
||||
project_members: manifest.data.project_members || [],
|
||||
company_profiles: manifest.data.company_profiles || [],
|
||||
referenced_profiles: manifest.data.referenced_profiles || [],
|
||||
companies: manifest.data.companies || [],
|
||||
},
|
||||
};
|
||||
|
||||
const companyRows = manifest.scope === 'company'
|
||||
? [manifest.data.company]
|
||||
: (manifest.data.companies || []);
|
||||
|
||||
const companyScopedManifest = {
|
||||
...normalizedManifest,
|
||||
data: {
|
||||
...normalizedManifest.data,
|
||||
companies: companyRows,
|
||||
},
|
||||
};
|
||||
|
||||
reportProgress(onProgress, 'Validating restore');
|
||||
await assertNoRestoreConflicts({
|
||||
...companyScopedManifest,
|
||||
data: {
|
||||
...companyScopedManifest.data,
|
||||
company: companyRows[0],
|
||||
companies: companyRows,
|
||||
},
|
||||
});
|
||||
|
||||
const referencedProfileIds = uniqueValues([
|
||||
...(companyScopedManifest.data.company_profiles || []).map(profile => profile.id),
|
||||
...(companyScopedManifest.data.project_members || []).map(member => member.profile_id),
|
||||
...(companyScopedManifest.data.tasks || []).map(task => task.assigned_to),
|
||||
...(companyScopedManifest.data.submissions || []).map(submission => submission.submitted_by),
|
||||
...(companyScopedManifest.data.invoices || []).map(invoice => invoice.created_by),
|
||||
]);
|
||||
const existingProfiles = referencedProfileIds.length
|
||||
? await selectByValues('profiles', 'id', referencedProfileIds, 'id')
|
||||
: [];
|
||||
const existingProfileIds = new Set(existingProfiles.map(profile => profile.id));
|
||||
|
||||
await uploadArchiveFiles(zip, manifest.storage_files, onProgress);
|
||||
|
||||
reportProgress(onProgress, 'Restoring database records');
|
||||
const restoredTasks = (companyScopedManifest.data.tasks || []).map(task => ({
|
||||
...task,
|
||||
assigned_to: existingProfileIds.has(task.assigned_to) ? task.assigned_to : null,
|
||||
}));
|
||||
const restoredSubmissions = (companyScopedManifest.data.submissions || []).map(submission => ({
|
||||
...submission,
|
||||
submitted_by: existingProfileIds.has(submission.submitted_by) ? submission.submitted_by : null,
|
||||
}));
|
||||
const restoredInvoices = (companyScopedManifest.data.invoices || []).map(invoice => ({
|
||||
...invoice,
|
||||
created_by: existingProfileIds.has(invoice.created_by) ? invoice.created_by : null,
|
||||
}));
|
||||
const restoredProjectMembers = (companyScopedManifest.data.project_members || []).filter(member => existingProfileIds.has(member.profile_id));
|
||||
const restoredCompanyProfileRows = (companyScopedManifest.data.company_profiles || [])
|
||||
.filter(profile => existingProfileIds.has(profile.id));
|
||||
|
||||
await insertRows('companies', companyRows);
|
||||
await insertRows('company_prices', companyScopedManifest.data.company_prices || []);
|
||||
await insertRows('projects', companyScopedManifest.data.projects || []);
|
||||
await insertRows('tasks', restoredTasks);
|
||||
await insertRows('submissions', restoredSubmissions);
|
||||
await insertRows('submission_files', companyScopedManifest.data.submission_files || []);
|
||||
await insertRows('deliveries', companyScopedManifest.data.deliveries || []);
|
||||
await insertRows('delivery_files', companyScopedManifest.data.delivery_files || []);
|
||||
await insertRows('invoices', restoredInvoices);
|
||||
await insertRows('invoice_items', companyScopedManifest.data.invoice_items || []);
|
||||
await insertRows('brand_books', companyScopedManifest.data.brand_books || []);
|
||||
await insertRows('project_members', restoredProjectMembers);
|
||||
|
||||
if (restoredCompanyProfileRows.length) {
|
||||
reportProgress(onProgress, 'Re-linking existing users');
|
||||
const profilesByCompany = restoredCompanyProfileRows.reduce((acc, profile) => {
|
||||
const key = profile.company_id;
|
||||
if (!key) return acc;
|
||||
if (!acc[key]) acc[key] = [];
|
||||
acc[key].push(profile.id);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
for (const [companyId, profileIds] of Object.entries(profilesByCompany)) {
|
||||
for (const batch of chunk(profileIds, ROW_BATCH_SIZE)) {
|
||||
const { error } = await supabase.from('profiles')
|
||||
.update({ company_id: companyId })
|
||||
.in('id', batch);
|
||||
if (error) throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
companyName: companyRows[0]?.name || manifest.company_name,
|
||||
companyCount: companyRows.length,
|
||||
missingProfiles: getMissingProfileSummary(existingProfileIds, {
|
||||
data: {
|
||||
company_profiles: companyScopedManifest.data.company_profiles || [],
|
||||
project_members: companyScopedManifest.data.project_members || [],
|
||||
tasks: companyScopedManifest.data.tasks || [],
|
||||
submissions: companyScopedManifest.data.submissions || [],
|
||||
invoices: companyScopedManifest.data.invoices || [],
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function restoreCompletedJobsArchive(file, options = {}) {
|
||||
const { onProgress } = options;
|
||||
const { zip, manifest } = await readAnyArchiveManifest(file, onProgress);
|
||||
if (manifest.scope !== 'completed_tasks') throw new Error('This archive is not a completed jobs archive.');
|
||||
|
||||
reportProgress(onProgress, 'Validating restore');
|
||||
const taskIds = uniqueValues((manifest.data.tasks || []).map(task => task.id));
|
||||
const submissionIds = uniqueValues((manifest.data.submissions || []).map(submission => submission.id));
|
||||
const deliveryIds = uniqueValues((manifest.data.deliveries || []).map(delivery => delivery.id));
|
||||
const companyIds = uniqueValues((manifest.company_refs || []).map(company => company.id));
|
||||
const projectIds = uniqueValues((manifest.project_refs || []).map(project => project.id));
|
||||
const invoiceItemIds = uniqueValues((manifest.data.invoice_items || []).map(item => item.id));
|
||||
|
||||
const [existingTasks, existingSubmissions, existingDeliveries, existingCompanies, existingProjects, existingInvoiceItems] = await Promise.all([
|
||||
taskIds.length ? selectByValues('tasks', 'id', taskIds, 'id') : Promise.resolve([]),
|
||||
submissionIds.length ? selectByValues('submissions', 'id', submissionIds, 'id') : Promise.resolve([]),
|
||||
deliveryIds.length ? selectByValues('deliveries', 'id', deliveryIds, 'id') : Promise.resolve([]),
|
||||
companyIds.length ? selectByValues('companies', 'id', companyIds, 'id') : Promise.resolve([]),
|
||||
projectIds.length ? selectByValues('projects', 'id', projectIds, 'id') : Promise.resolve([]),
|
||||
invoiceItemIds.length ? selectByValues('invoice_items', 'id', invoiceItemIds, 'id') : Promise.resolve([]),
|
||||
]);
|
||||
|
||||
if (existingTasks.length || existingSubmissions.length || existingDeliveries.length) {
|
||||
throw new Error('Restore blocked: one or more archived job IDs already exist.');
|
||||
}
|
||||
if (existingCompanies.length !== companyIds.length) {
|
||||
throw new Error('Restore blocked: one or more original companies no longer exist.');
|
||||
}
|
||||
if (existingProjects.length !== projectIds.length) {
|
||||
throw new Error('Restore blocked: one or more original projects no longer exist.');
|
||||
}
|
||||
|
||||
const referencedProfileIds = uniqueValues([
|
||||
...(manifest.data.tasks || []).map(task => task.assigned_to),
|
||||
...(manifest.data.submissions || []).map(submission => submission.submitted_by),
|
||||
]);
|
||||
const existingProfiles = referencedProfileIds.length
|
||||
? await selectByValues('profiles', 'id', referencedProfileIds, 'id')
|
||||
: [];
|
||||
const existingProfileIds = new Set(existingProfiles.map(profile => profile.id));
|
||||
|
||||
await uploadArchiveFiles(zip, manifest.storage_files, onProgress);
|
||||
|
||||
reportProgress(onProgress, 'Restoring database records');
|
||||
const restoredTasks = (manifest.data.tasks || []).map(task => ({
|
||||
...task,
|
||||
assigned_to: existingProfileIds.has(task.assigned_to) ? task.assigned_to : null,
|
||||
}));
|
||||
const restoredSubmissions = (manifest.data.submissions || []).map(submission => ({
|
||||
...submission,
|
||||
submitted_by: existingProfileIds.has(submission.submitted_by) ? submission.submitted_by : null,
|
||||
}));
|
||||
|
||||
await insertRows('tasks', restoredTasks);
|
||||
await insertRows('submissions', restoredSubmissions);
|
||||
await insertRows('submission_files', manifest.data.submission_files || []);
|
||||
await insertRows('deliveries', manifest.data.deliveries || []);
|
||||
await insertRows('delivery_files', manifest.data.delivery_files || []);
|
||||
|
||||
const existingInvoiceItemIds = new Set(existingInvoiceItems.map(item => item.id));
|
||||
const invoiceItemsToInsert = [];
|
||||
const invoiceItemsToUpdate = [];
|
||||
for (const item of manifest.data.invoice_items || []) {
|
||||
if (existingInvoiceItemIds.has(item.id)) invoiceItemsToUpdate.push(item);
|
||||
else invoiceItemsToInsert.push(item);
|
||||
}
|
||||
await insertRows('invoice_items', invoiceItemsToInsert);
|
||||
for (const item of invoiceItemsToUpdate) {
|
||||
const { error } = await supabase.from('invoice_items')
|
||||
.update({ task_id: item.task_id, submission_id: item.submission_id })
|
||||
.eq('id', item.id);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
taskCount: restoredTasks.length,
|
||||
clearedAssignments: restoredTasks.filter(task => !task.assigned_to).length,
|
||||
clearedSubmissionUsers: restoredSubmissions.filter(submission => !submission.submitted_by).length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function restoreBrandBookArchive(file, options = {}) {
|
||||
const { onProgress } = options;
|
||||
const { zip, manifest } = await readAnyArchiveManifest(file, onProgress);
|
||||
if (!['brand_book', 'brand_books'].includes(manifest.scope)) {
|
||||
throw new Error('This archive is not a brand book archive.');
|
||||
}
|
||||
|
||||
reportProgress(onProgress, 'Validating restore');
|
||||
const books = manifest.scope === 'brand_book'
|
||||
? [manifest.data.brand_book]
|
||||
: (manifest.data.brand_books || []);
|
||||
|
||||
const existingBookIds = uniqueValues(books.map(book => book.id));
|
||||
const existingBooks = existingBookIds.length ? await selectByValues('brand_books', 'id', existingBookIds, 'id') : [];
|
||||
if (existingBooks.length) throw new Error('Restore blocked: one or more brand book IDs already exist.');
|
||||
|
||||
const companyIds = uniqueValues(books.map(book => book.client_id));
|
||||
const existingCompanies = companyIds.length ? await selectByValues('companies', 'id', companyIds, 'id') : [];
|
||||
const existingCompanyIds = new Set(existingCompanies.map(company => company.id));
|
||||
|
||||
const restoredBooks = books.map(book => (
|
||||
book.client_id && !existingCompanyIds.has(book.client_id)
|
||||
? { ...book, client_id: null }
|
||||
: { ...book }
|
||||
));
|
||||
|
||||
await uploadArchiveFiles(zip, manifest.storage_files, onProgress);
|
||||
reportProgress(onProgress, 'Restoring database records');
|
||||
await insertRows('brand_books', restoredBooks);
|
||||
|
||||
return {
|
||||
bookCount: restoredBooks.length,
|
||||
unlinkedCount: restoredBooks.filter(book => !book.client_id).length,
|
||||
clientName: restoredBooks[0]?.client_name,
|
||||
};
|
||||
}
|
||||
+554
-159
@@ -14,33 +14,252 @@ function formatDate(dateStr) {
|
||||
return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
// Accepts File, data URL string, or https URL string — returns data URL
|
||||
function formatCoverAddress(address) {
|
||||
const parts = String(address || '')
|
||||
.split(',')
|
||||
.map(part => part.trim())
|
||||
.filter(Boolean);
|
||||
if (parts.length >= 3) {
|
||||
const countryless = parts[parts.length - 1].toLowerCase() === 'united states' || parts[parts.length - 1].toLowerCase() === 'usa'
|
||||
? parts.slice(0, -1)
|
||||
: parts;
|
||||
return [
|
||||
countryless[0],
|
||||
countryless.slice(1).join(', '),
|
||||
].filter(Boolean);
|
||||
}
|
||||
return address ? [address] : [];
|
||||
}
|
||||
|
||||
// Parse JPEG EXIF orientation tag from raw bytes (returns 1–8; 1 = normal).
|
||||
function parseExifOrientation(bytes) {
|
||||
if (bytes.length < 4 || bytes[0] !== 0xFF || bytes[1] !== 0xD8) return 1;
|
||||
let pos = 2;
|
||||
while (pos < bytes.length - 4) {
|
||||
if (bytes[pos] !== 0xFF) break;
|
||||
const marker = bytes[pos + 1];
|
||||
if (marker === 0xDA) break; // SOS — no more metadata
|
||||
const segLen = (bytes[pos + 2] << 8) | bytes[pos + 3];
|
||||
if (marker === 0xE1 && segLen >= 14 &&
|
||||
bytes[pos+4]===0x45 && bytes[pos+5]===0x78 && bytes[pos+6]===0x69 &&
|
||||
bytes[pos+7]===0x66 && bytes[pos+8]===0x00 && bytes[pos+9]===0x00) {
|
||||
const tiff = pos + 10;
|
||||
const le = bytes[tiff] === 0x49;
|
||||
const r16 = (o) => le ? (bytes[o]|(bytes[o+1]<<8)) : ((bytes[o]<<8)|bytes[o+1]);
|
||||
const r32 = (o) => le
|
||||
? ((bytes[o]|(bytes[o+1]<<8)|(bytes[o+2]<<16)|(bytes[o+3]<<24))>>>0)
|
||||
: (((bytes[o]<<24)|(bytes[o+1]<<16)|(bytes[o+2]<<8)|bytes[o+3])>>>0);
|
||||
const ifd = tiff + r32(tiff + 4);
|
||||
if (ifd + 2 > bytes.length) break;
|
||||
const n = r16(ifd);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const e = ifd + 2 + i * 12;
|
||||
if (e + 12 > bytes.length) break;
|
||||
if (r16(e) === 0x0112) return r16(e + 8);
|
||||
}
|
||||
}
|
||||
pos += 2 + segLen;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Apply EXIF orientation correction to a Blob → returns corrected data URL.
|
||||
//
|
||||
// Strategy: try createImageBitmap(imageOrientation:'none') first to get raw pixels,
|
||||
// then apply explicit canvas transforms. BUT some browsers (older Safari) ignore the
|
||||
// option and return already-corrected pixels — detect this by comparing bitmap
|
||||
// dimensions to what the EXIF orientation implies for raw pixels, and skip the
|
||||
// transform if the browser already corrected. Falls back to img→canvas if
|
||||
// createImageBitmap throws.
|
||||
async function correctOrientation(blob) {
|
||||
if (!blob) return null;
|
||||
const isPng = blob.type === 'image/png';
|
||||
const toDataUrl = (b) => new Promise((res) => {
|
||||
const r = new FileReader();
|
||||
r.onload = (e) => res(e.target.result);
|
||||
r.onerror = () => res(null);
|
||||
r.readAsDataURL(b);
|
||||
});
|
||||
if (isPng) return toDataUrl(blob);
|
||||
|
||||
let orientation = 1;
|
||||
let ab = null;
|
||||
try { ab = await blob.arrayBuffer(); orientation = parseExifOrientation(new Uint8Array(ab)); }
|
||||
catch { /* treat as normal */ }
|
||||
|
||||
if (orientation <= 1) return toDataUrl(blob);
|
||||
|
||||
// Helper: build canvas with explicit transform if needed.
|
||||
// swapAxes orientations (5-8): raw JPEG stores landscape pixels.
|
||||
// If browser ignored imageOrientation:'none', it returned corrected (portrait) pixels —
|
||||
// detect this by checking bmpW > bmpH (raw=landscape) vs bmpW < bmpH (corrected=portrait).
|
||||
const applyToCanvas = (source, bmpW, bmpH) => {
|
||||
const swapAxes = orientation >= 5;
|
||||
// For swap-axes orientations: raw = landscape (w > h), corrected = portrait (w < h)
|
||||
const isRaw = swapAxes ? (bmpW > bmpH) : true;
|
||||
const c = document.createElement('canvas');
|
||||
const ctx = c.getContext('2d');
|
||||
if (isRaw && swapAxes) { c.width = bmpH; c.height = bmpW; }
|
||||
else { c.width = bmpW; c.height = bmpH; }
|
||||
if (isRaw) {
|
||||
switch (orientation) {
|
||||
case 2: ctx.transform(-1, 0, 0, 1, bmpW, 0); break;
|
||||
case 3: ctx.transform(-1, 0, 0, -1, bmpW, bmpH); break;
|
||||
case 4: ctx.transform( 1, 0, 0, -1, 0, bmpH); break;
|
||||
case 5: ctx.transform( 0, 1, 1, 0, 0, 0); break;
|
||||
case 6: ctx.transform( 0, 1, -1, 0, bmpH, 0); break;
|
||||
case 7: ctx.transform( 0, -1, -1, 0, bmpH, bmpW); break;
|
||||
case 8: ctx.transform( 0, -1, 1, 0, 0, bmpW); break;
|
||||
}
|
||||
}
|
||||
ctx.drawImage(source, 0, 0);
|
||||
return c.toDataURL('image/jpeg', 0.92);
|
||||
};
|
||||
|
||||
// Primary: createImageBitmap with imageOrientation:'none'
|
||||
try {
|
||||
const src = ab ? new Blob([ab], { type: blob.type }) : blob;
|
||||
const bmp = await createImageBitmap(src, { imageOrientation: 'none' });
|
||||
const result = applyToCanvas(bmp, bmp.width, bmp.height);
|
||||
bmp.close();
|
||||
return result;
|
||||
} catch { /* fall through */ }
|
||||
|
||||
// Fallback: img→canvas (browser may auto-correct EXIF — dimension check handles both cases)
|
||||
const dataUrl = await toDataUrl(blob);
|
||||
if (!dataUrl) return null;
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
try { resolve(applyToCanvas(img, img.naturalWidth, img.naturalHeight)); }
|
||||
catch { resolve(dataUrl); }
|
||||
};
|
||||
img.onerror = () => resolve(dataUrl);
|
||||
img.src = dataUrl;
|
||||
});
|
||||
}
|
||||
|
||||
function getDataUrlMimeType(dataUrl) {
|
||||
if (typeof dataUrl !== 'string') return '';
|
||||
const match = dataUrl.match(/^data:([^;,]+)[;,]/i);
|
||||
return match?.[1]?.toLowerCase() || '';
|
||||
}
|
||||
|
||||
function isPdfSafeDataUrl(dataUrl) {
|
||||
const mime = getDataUrlMimeType(dataUrl);
|
||||
return mime === 'image/png' || mime === 'image/jpeg';
|
||||
}
|
||||
|
||||
function getJsPdfFormat(dataUrl) {
|
||||
return getDataUrlMimeType(dataUrl) === 'image/png' ? 'PNG' : 'JPEG';
|
||||
}
|
||||
|
||||
async function ensurePdfSafeDataUrl(dataUrl, preferPng = false) {
|
||||
if (!dataUrl) return null;
|
||||
if (isPdfSafeDataUrl(dataUrl)) return dataUrl;
|
||||
|
||||
const img = await loadImage(dataUrl);
|
||||
if (!img) return null;
|
||||
|
||||
const c = document.createElement('canvas');
|
||||
c.width = img.naturalWidth || img.width || 1;
|
||||
c.height = img.naturalHeight || img.height || 1;
|
||||
const ctx = c.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
|
||||
if (!preferPng) {
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, c.width, c.height);
|
||||
}
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
return preferPng
|
||||
? c.toDataURL('image/png')
|
||||
: c.toDataURL('image/jpeg', 0.92);
|
||||
}
|
||||
|
||||
function addDataUrlImage(doc, dataUrl, x, y, w, h) {
|
||||
if (!dataUrl) return;
|
||||
doc.addImage(dataUrl, getJsPdfFormat(dataUrl), x, y, w, h);
|
||||
}
|
||||
|
||||
// Accepts File/Blob, data URL string, or https/blob URL string.
|
||||
// Returns EXIF-corrected, PDF-safe data URL, or null on failure.
|
||||
// 15-second fetch timeout prevents infinite hangs on stalled network requests.
|
||||
async function resolvePhoto(source) {
|
||||
if (!source) return null;
|
||||
if (source instanceof File) {
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => resolve(e.target.result);
|
||||
reader.onerror = () => resolve(null);
|
||||
reader.readAsDataURL(source);
|
||||
});
|
||||
if (source instanceof File || source instanceof Blob) {
|
||||
const corrected = await correctOrientation(source);
|
||||
return ensurePdfSafeDataUrl(corrected, source.type === 'image/png');
|
||||
}
|
||||
if (typeof source === 'string') {
|
||||
if (source.startsWith('data:')) return source;
|
||||
if (source.startsWith('data:')) {
|
||||
return ensurePdfSafeDataUrl(source, getDataUrlMimeType(source) === 'image/png');
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(source);
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), 15000);
|
||||
const resp = await fetch(source, { signal: ctrl.signal });
|
||||
clearTimeout(timer);
|
||||
const blob = await resp.blob();
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => resolve(e.target.result);
|
||||
reader.onerror = () => resolve(null);
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
const corrected = await correctOrientation(blob);
|
||||
return ensurePdfSafeDataUrl(corrected, blob.type === 'image/png');
|
||||
} catch { return null; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Downsample a data-URL image to 300 DPI for its rendered display size before
|
||||
// embedding in jsPDF. PNGs are preserved only when needed for transparency.
|
||||
// Images already at or below the target pixel count are returned as-is.
|
||||
const PX_PER_PT = 300 / 72;
|
||||
const MAP_PX_PER_PT = 180 / 72;
|
||||
const SIGN_PX_PER_PT = 180 / 72;
|
||||
const THUMB_PX_PER_PT = 110 / 72;
|
||||
|
||||
function toMapJpeg(dataUrl, img, displayPtW, displayPtH, quality = 0.78) {
|
||||
if (!dataUrl || !img) return dataUrl;
|
||||
const tW = Math.max(1, Math.round(displayPtW * MAP_PX_PER_PT));
|
||||
const tH = Math.max(1, Math.round(displayPtH * MAP_PX_PER_PT));
|
||||
if (!dataUrl.startsWith('data:image/png') && img.naturalWidth <= tW && img.naturalHeight <= tH) return dataUrl;
|
||||
const c = document.createElement('canvas');
|
||||
c.width = tW;
|
||||
c.height = tH;
|
||||
c.getContext('2d').drawImage(img, 0, 0, tW, tH);
|
||||
return c.toDataURL('image/jpeg', quality);
|
||||
}
|
||||
|
||||
function toCoverImage(dataUrl, img, displayPtW, displayPtH, quality = 0.82, pxPerPt = PX_PER_PT) {
|
||||
if (!dataUrl || !img) return null;
|
||||
const tW = Math.max(1, Math.round(displayPtW * pxPerPt));
|
||||
const tH = Math.max(1, Math.round(displayPtH * pxPerPt));
|
||||
const srcAspect = img.naturalWidth / img.naturalHeight;
|
||||
const dstAspect = displayPtW / displayPtH;
|
||||
let sx = 0;
|
||||
let sy = 0;
|
||||
let sW = img.naturalWidth;
|
||||
let sH = img.naturalHeight;
|
||||
|
||||
if (srcAspect > dstAspect) {
|
||||
sW = img.naturalHeight * dstAspect;
|
||||
sx = (img.naturalWidth - sW) / 2;
|
||||
} else {
|
||||
sH = img.naturalWidth / dstAspect;
|
||||
sy = (img.naturalHeight - sH) / 2;
|
||||
}
|
||||
|
||||
const c = document.createElement('canvas');
|
||||
c.width = tW;
|
||||
c.height = tH;
|
||||
const ctx = c.getContext('2d');
|
||||
ctx.drawImage(img, sx, sy, sW, sH, 0, 0, tW, tH);
|
||||
const format = dataUrl.startsWith('data:image/png') ? 'PNG' : 'JPEG';
|
||||
const croppedDataUrl = format === 'PNG'
|
||||
? c.toDataURL('image/png')
|
||||
: c.toDataURL('image/jpeg', quality);
|
||||
return { dataUrl: croppedDataUrl, format };
|
||||
}
|
||||
|
||||
function loadImage(src) {
|
||||
return new Promise((resolve) => {
|
||||
if (!src) return resolve(null);
|
||||
@@ -98,13 +317,11 @@ const BOLCHOZ_FOOTER_H = BOLCHOZ_LOGO_H + 8; // space reserved at bottom for foo
|
||||
|
||||
function addBolchozFooter(doc, pageNum, totalPages, clientLogoDataUrl, clientLogoImg) {
|
||||
const logoY = H - MARGIN - BOLCHOZ_LOGO_H;
|
||||
const pgDivY = H - MARGIN - 3.5; // vertical center of 10pt text
|
||||
|
||||
let footerLogoW = 0;
|
||||
if (clientLogoDataUrl && clientLogoImg) {
|
||||
const aspect = clientLogoImg.naturalWidth / clientLogoImg.naturalHeight;
|
||||
footerLogoW = BOLCHOZ_LOGO_H * aspect;
|
||||
doc.addImage(clientLogoDataUrl, MARGIN, logoY, footerLogoW, BOLCHOZ_LOGO_H);
|
||||
addDataUrlImage(doc, clientLogoDataUrl, MARGIN, logoY, footerLogoW, BOLCHOZ_LOGO_H);
|
||||
}
|
||||
|
||||
const pgLabel = `Page ${String(pageNum).padStart(2, '0')} of ${String(totalPages).padStart(2, '0')}`;
|
||||
@@ -113,14 +330,6 @@ function addBolchozFooter(doc, pageNum, totalPages, clientLogoDataUrl, clientLog
|
||||
doc.setTextColor(150, 150, 150);
|
||||
doc.text(pgLabel, W - MARGIN, H - MARGIN, { align: 'right' });
|
||||
|
||||
const pgLabelW = doc.getTextWidth(pgLabel);
|
||||
const divStartX = MARGIN + (footerLogoW > 0 ? footerLogoW + 12 : 0);
|
||||
const divEndX = W - MARGIN - pgLabelW - 10;
|
||||
if (divEndX > divStartX) {
|
||||
doc.setDrawColor(210, 210, 210);
|
||||
doc.setLineWidth(0.5);
|
||||
doc.line(divStartX, pgDivY, divEndX, pgDivY);
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateBrandBookEditorPDF(data) {
|
||||
@@ -131,7 +340,7 @@ export async function generateBrandBookEditorPDF(data) {
|
||||
clientContactName, clientContactEmail, clientContactPhone,
|
||||
approvedDate, approvalNotes } = data;
|
||||
|
||||
const doc = new jsPDF({ orientation: 'landscape', format: 'letter', unit: 'pt' });
|
||||
const doc = new jsPDF({ orientation: 'landscape', format: 'letter', unit: 'pt', compress: true });
|
||||
|
||||
// Load assets
|
||||
const logo = await loadImage('/fourge-logo.png');
|
||||
@@ -147,14 +356,28 @@ export async function generateBrandBookEditorPDF(data) {
|
||||
]);
|
||||
const clientLogoImg = clientLogoDataUrl ? await loadImage(clientLogoDataUrl) : null;
|
||||
const signPhotoDataUrls = await Promise.all(signs.map(s => resolvePhoto(s.photoSource)));
|
||||
const signExistingPhotoDataUrls = await Promise.all(signs.map(s => resolvePhoto(s.existingPhotoSource)));
|
||||
const signRecommendationPhotoDataUrls = await Promise.all(signs.map(s => resolvePhoto(s.recommendationPhotoSource)));
|
||||
const signDetailPhotoDataUrls = await Promise.all(signs.map(s => resolvePhoto(s.signDetailPhotoSource)));
|
||||
const sitePhotoDataUrls = await Promise.all((sitePhotoSources || []).map(s => resolvePhoto(s)));
|
||||
const validSitePhotos = sitePhotoDataUrls.filter(Boolean);
|
||||
const PHOTOS_PER_PAGE = 16;
|
||||
|
||||
// Count pages
|
||||
const INV_LABEL_H = 18;
|
||||
const TABLE_ROW_H = 18;
|
||||
const invFirstH = H - MARGIN - BOLCHOZ_FOOTER_H - (MARGIN + INV_LABEL_H);
|
||||
const invFirstCap = Math.max(0, Math.floor(invFirstH / TABLE_ROW_H) - 1);
|
||||
const invContH = invFirstH; // same geometry on cont. pages
|
||||
const invContCap = Math.max(1, Math.floor(invContH / TABLE_ROW_H) - 1);
|
||||
const extraInvPages = template === 'bolchoz' && signs.length > invFirstCap
|
||||
? Math.ceil((signs.length - invFirstCap) / invContCap)
|
||||
: 0;
|
||||
|
||||
let totalPages = 1; // cover
|
||||
if (siteMapDataUrl) totalPages++; // site map page
|
||||
totalPages++; // sign inventory
|
||||
totalPages += extraInvPages; // overflow inventory pages
|
||||
totalPages += signs.length; // sign pages
|
||||
totalPages += Math.ceil(validSitePhotos.length / PHOTOS_PER_PAGE); // site photo pages
|
||||
|
||||
@@ -186,7 +409,7 @@ export async function generateBrandBookEditorPDF(data) {
|
||||
let dW, dH;
|
||||
if (ratio >= 1) { dW = logoBoxSize; dH = dW / ratio; }
|
||||
else { dH = logoBoxSize; dW = dH * ratio; }
|
||||
doc.addImage(projectLogoDataUrl, logoBoxX, logoBoxY, dW, dH);
|
||||
addDataUrlImage(doc, projectLogoDataUrl, logoBoxX, logoBoxY, dW, dH);
|
||||
}
|
||||
} else {
|
||||
doc.setFontSize(9);
|
||||
@@ -238,18 +461,14 @@ export async function generateBrandBookEditorPDF(data) {
|
||||
doc.setTextColor(30, 30, 30);
|
||||
if (revisionDate) doc.text(formatDate(revisionDate), rtx(formatDate(revisionDate)), ty);
|
||||
|
||||
const sepY = logoBoxY + logoBoxSize + 10;
|
||||
|
||||
const botY = sepY + 14;
|
||||
const halfW = (W - MARGIN * 2 - 20) / 2;
|
||||
const rightColX = MARGIN + halfW + 20;
|
||||
|
||||
// ── Bottom left: Customer (anchored to bottom-left corner) ──────────────────
|
||||
const addrText = customerAddress || siteAddress || '';
|
||||
const addrLineH = 11;
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
const addrLines = addrText ? doc.splitTextToSize(addrText, halfW) : [];
|
||||
const addrLines = formatCoverAddress(addrText);
|
||||
|
||||
// Work bottom-up to anchor to H - MARGIN
|
||||
const lY_addr = H - MARGIN;
|
||||
@@ -364,7 +583,7 @@ export async function generateBrandBookEditorPDF(data) {
|
||||
dH = clientLogoH; dW = dH * ratio;
|
||||
dx = clBoxLeft + (clientLogoW - dW) / 2; dy = clBoxTop;
|
||||
}
|
||||
doc.addImage(clientLogoDataUrl, dx, dy, dW, dH);
|
||||
addDataUrlImage(doc, clientLogoDataUrl, dx, dy, dW, dH);
|
||||
}
|
||||
} else {
|
||||
doc.setFontSize(8);
|
||||
@@ -390,6 +609,25 @@ export async function generateBrandBookEditorPDF(data) {
|
||||
}
|
||||
|
||||
const cY = H / 2;
|
||||
if (projectLogoDataUrl) {
|
||||
const pImg = await loadImage(projectLogoDataUrl);
|
||||
if (pImg) {
|
||||
const logoAreaW = 170;
|
||||
const logoAreaH = 120;
|
||||
const ratio = pImg.naturalWidth / pImg.naturalHeight;
|
||||
let dW;
|
||||
let dH;
|
||||
if (ratio >= logoAreaW / logoAreaH) {
|
||||
dW = logoAreaW;
|
||||
dH = dW / ratio;
|
||||
} else {
|
||||
dH = logoAreaH;
|
||||
dW = dH * ratio;
|
||||
}
|
||||
addDataUrlImage(doc, projectLogoDataUrl, W / 2 - dW / 2, cY - 190, dW, dH);
|
||||
}
|
||||
}
|
||||
|
||||
doc.setFontSize(9); doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(...ACCENT); doc.setCharSpace(3);
|
||||
doc.text('BRAND BOOK', W / 2, cY - 50, { align: 'center' });
|
||||
@@ -407,14 +645,20 @@ export async function generateBrandBookEditorPDF(data) {
|
||||
doc.text(projectName, W / 2, cY + 14, { align: 'center' });
|
||||
}
|
||||
|
||||
const coverAddressLines = formatCoverAddress(customerAddress || siteAddress || '');
|
||||
if (coverAddressLines.length > 0) {
|
||||
doc.setFontSize(8); doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(150, 150, 150);
|
||||
doc.text(coverAddressLines, W / 2, cY + 36, { align: 'center' });
|
||||
}
|
||||
|
||||
const metaParts = [];
|
||||
if (siteAddress) metaParts.push(siteAddress);
|
||||
if (displayDate) metaParts.push(displayDate);
|
||||
if (preparedBy) metaParts.push(`Prepared by ${preparedBy}`);
|
||||
if (metaParts.length > 0) {
|
||||
doc.setFontSize(8); doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(150, 150, 150);
|
||||
doc.text(metaParts.join(' · '), W / 2, cY + 36, { align: 'center' });
|
||||
doc.text(metaParts.join(' · '), W / 2, cY + 36 + (coverAddressLines.length * 10), { align: 'center' });
|
||||
}
|
||||
|
||||
doc.setFontSize(9); doc.setFont('helvetica', 'bold');
|
||||
@@ -428,17 +672,7 @@ export async function generateBrandBookEditorPDF(data) {
|
||||
pageNum++;
|
||||
doc.addPage();
|
||||
if (template === 'bolchoz') {
|
||||
addBolchozFooter(doc, pageNum, totalPages, clientLogoDataUrl, clientLogoImg);
|
||||
|
||||
// "Site Map" header top-left
|
||||
doc.setFontSize(14);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(160, 160, 160);
|
||||
doc.setCharSpace(1.5);
|
||||
doc.text('SITE MAP', MARGIN, MARGIN);
|
||||
doc.setCharSpace(0);
|
||||
|
||||
const smLabelH = 22; // space below label before map
|
||||
const smLabelH = 18; // space below label before map
|
||||
const smTop = MARGIN + smLabelH;
|
||||
const smBottom = H - MARGIN - BOLCHOZ_FOOTER_H;
|
||||
const smW = W - MARGIN * 2;
|
||||
@@ -450,12 +684,29 @@ export async function generateBrandBookEditorPDF(data) {
|
||||
const boxAspect = smW / smH;
|
||||
let dw, dh, dx, dy;
|
||||
if (aspect > boxAspect) {
|
||||
dw = smW; dh = dw / aspect; dx = MARGIN; dy = smTop + (smH - dh) / 2;
|
||||
dh = smH; dw = dh * aspect;
|
||||
dx = MARGIN - (dw - smW) / 2; dy = smTop;
|
||||
} else {
|
||||
dh = smH; dw = dh * aspect; dx = MARGIN + (smW - dw) / 2; dy = smTop;
|
||||
dw = smW; dh = dw / aspect;
|
||||
dx = MARGIN; dy = smTop - (dh - smH) / 2;
|
||||
}
|
||||
doc.addImage(siteMapDataUrl, dx, dy, dw, dh);
|
||||
addDataUrlImage(doc, toMapJpeg(siteMapDataUrl, smImg, dw, dh), dx, dy, dw, dh);
|
||||
// Mask overflow with white overlays
|
||||
doc.setFillColor(255, 255, 255);
|
||||
doc.rect(0, 0, W, smTop, 'F');
|
||||
doc.rect(0, smTop + smH, W, H - smTop - smH, 'F');
|
||||
doc.rect(0, smTop, MARGIN, smH, 'F');
|
||||
doc.rect(MARGIN + smW, smTop, W - MARGIN - smW, smH, 'F');
|
||||
}
|
||||
|
||||
// Footer and header drawn last so they sit on top of overlays
|
||||
addBolchozFooter(doc, pageNum, totalPages, clientLogoDataUrl, clientLogoImg);
|
||||
doc.setFontSize(14);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(160, 160, 160);
|
||||
doc.setCharSpace(1.5);
|
||||
doc.text('SITE MAP', MARGIN, MARGIN + 10);
|
||||
doc.setCharSpace(0);
|
||||
} else {
|
||||
addHeader(doc, logo, logoW, logoH, 'Site Map', pageNum, totalPages);
|
||||
addFooter(doc, clientName, displayDate);
|
||||
@@ -475,7 +726,7 @@ export async function generateBrandBookEditorPDF(data) {
|
||||
}
|
||||
doc.setDrawColor(200, 200, 200); doc.setLineWidth(0.4);
|
||||
doc.rect(MARGIN, smTop, smW, smH);
|
||||
doc.addImage(siteMapDataUrl, dx, dy, dw, dh);
|
||||
addDataUrlImage(doc, toMapJpeg(siteMapDataUrl, smImg, dw, dh), dx, dy, dw, dh);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -484,7 +735,8 @@ export async function generateBrandBookEditorPDF(data) {
|
||||
pageNum++;
|
||||
doc.addPage();
|
||||
|
||||
const invContentTop = template === 'bolchoz' ? MARGIN : HEADER_H + 16;
|
||||
const invLabelH = 18; // space for "FLOOR PLAN" / "SIGN INVENTORY" labels
|
||||
const invContentTop = template === 'bolchoz' ? MARGIN + invLabelH : HEADER_H + 16;
|
||||
const invContentBottom = template === 'bolchoz' ? H - MARGIN - BOLCHOZ_FOOTER_H : H - 30;
|
||||
const invContentH = invContentBottom - invContentTop;
|
||||
|
||||
@@ -513,7 +765,7 @@ export async function generateBrandBookEditorPDF(data) {
|
||||
drawW = mapW; drawH = mapW / imgAspect;
|
||||
drawX = mapX; drawY = mapY - (drawH - invContentH) / 2;
|
||||
}
|
||||
doc.addImage(inventoryMapDataUrl, drawX, drawY, drawW, drawH);
|
||||
addDataUrlImage(doc, toMapJpeg(inventoryMapDataUrl, invMapImg, drawW, drawH), drawX, drawY, drawW, drawH);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -531,6 +783,15 @@ export async function generateBrandBookEditorPDF(data) {
|
||||
doc.text('No Map Available', mapX + mapW / 2, mapY + invContentH / 2, { align: 'center' });
|
||||
}
|
||||
|
||||
// Draw page labels on top of overlays
|
||||
doc.setFontSize(14);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(160, 160, 160);
|
||||
doc.setCharSpace(1.5);
|
||||
doc.text('FLOOR PLAN', MARGIN, MARGIN + 10);
|
||||
doc.text('SIGN INVENTORY', tableX, MARGIN + 10);
|
||||
doc.setCharSpace(0);
|
||||
|
||||
addBolchozFooter(doc, pageNum, totalPages, clientLogoDataUrl, clientLogoImg);
|
||||
} else {
|
||||
addHeader(doc, logo, logoW, logoH, 'Sign Inventory', pageNum, totalPages);
|
||||
@@ -548,7 +809,7 @@ export async function generateBrandBookEditorPDF(data) {
|
||||
drawH = invContentH; drawW = invContentH * imgAspect;
|
||||
drawX = mapX + (mapW - drawW) / 2; drawY = mapY;
|
||||
}
|
||||
doc.addImage(inventoryMapDataUrl, drawX, drawY, drawW, drawH);
|
||||
addDataUrlImage(doc, toMapJpeg(inventoryMapDataUrl, invMapImg, drawW, drawH), drawX, drawY, drawW, drawH);
|
||||
}
|
||||
} else {
|
||||
doc.setFontSize(14);
|
||||
@@ -558,7 +819,40 @@ export async function generateBrandBookEditorPDF(data) {
|
||||
}
|
||||
}
|
||||
|
||||
drawInventoryTable(doc, signs, tableX, invContentTop, tableW, invContentH, template);
|
||||
let nextSignIdx = drawInventoryTable(doc, signs, tableX, invContentTop, tableW, invContentH, template, 0);
|
||||
|
||||
// ─── SIGN INVENTORY CONTINUATION PAGES (bolchoz) ─────────────────────────────
|
||||
while (template === 'bolchoz' && nextSignIdx < signs.length) {
|
||||
pageNum++;
|
||||
doc.addPage();
|
||||
addBolchozFooter(doc, pageNum, totalPages, clientLogoDataUrl, clientLogoImg);
|
||||
|
||||
const contTop = MARGIN + INV_LABEL_H;
|
||||
const contH = H - MARGIN - BOLCHOZ_FOOTER_H - contTop;
|
||||
const contW = W - MARGIN * 2;
|
||||
|
||||
doc.setFontSize(14);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(160, 160, 160);
|
||||
doc.setCharSpace(1.5);
|
||||
doc.text('SIGN INVENTORY', MARGIN, MARGIN + 10);
|
||||
doc.setCharSpace(0);
|
||||
|
||||
nextSignIdx = drawInventoryTable(doc, signs, MARGIN, contTop, contW, contH, template, nextSignIdx);
|
||||
}
|
||||
|
||||
// ─── Cover-fit helper (maintains aspect ratio, fills box with centered crop) ──
|
||||
// Images are downsampled to 300 DPI for their rendered display size before
|
||||
// embedding — keeps file size small while maintaining print quality.
|
||||
const drawCover = async (dataUrl, bx, by, bw, bh, options = {}) => {
|
||||
const img = await loadImage(dataUrl);
|
||||
if (!img) return;
|
||||
const cropped = toCoverImage(dataUrl, img, bw, bh, options.quality ?? 0.8, options.pxPerPt ?? SIGN_PX_PER_PT);
|
||||
if (!cropped) return;
|
||||
doc.setFillColor(245, 245, 245);
|
||||
doc.rect(bx, by, bw, bh, 'F');
|
||||
doc.addImage(cropped.dataUrl, cropped.format, bx, by, bw, bh);
|
||||
};
|
||||
|
||||
// ─── SIGN PAGES ───────────────────────────────────────────────────────────────
|
||||
for (let i = 0; i < signs.length; i++) {
|
||||
@@ -576,98 +870,197 @@ export async function generateBrandBookEditorPDF(data) {
|
||||
|
||||
const top = template === 'bolchoz' ? MARGIN : HEADER_H + 16;
|
||||
const bottom = template === 'bolchoz' ? H - MARGIN - BOLCHOZ_FOOTER_H : H - 30;
|
||||
const availH = bottom - top;
|
||||
const photoW = (W - MARGIN * 2 - 20) * 0.45;
|
||||
const specsX = MARGIN + photoW + 20;
|
||||
const specsW = W - MARGIN - specsX;
|
||||
|
||||
if (photoDataUrl) {
|
||||
const photoImg = await loadImage(photoDataUrl);
|
||||
if (photoImg) {
|
||||
const imgAspect = photoImg.naturalWidth / photoImg.naturalHeight;
|
||||
const boxAspect = photoW / availH;
|
||||
let dw, dh, dx, dy;
|
||||
if (imgAspect > boxAspect) {
|
||||
dw = photoW; dh = photoW / imgAspect;
|
||||
dx = MARGIN; dy = top + (availH - dh) / 2;
|
||||
} else {
|
||||
dh = availH; dw = availH * imgAspect;
|
||||
dx = MARGIN + (photoW - dw) / 2; dy = top;
|
||||
if (template === 'bolchoz') {
|
||||
// ── Bolchoz sign page ──────────────────────────────────────────────────────
|
||||
const TEAL = [80, 80, 80]; // dark gray divider
|
||||
const PHOTO_W = 288; // 4"
|
||||
const PHOTO_H = 180; // 2.5"
|
||||
const photoX = W - MARGIN - PHOTO_W;
|
||||
const textW = photoX - MARGIN - 16;
|
||||
|
||||
// Diamond header
|
||||
const dr = 20;
|
||||
const dcx = MARGIN + dr;
|
||||
const dcy = top + dr;
|
||||
|
||||
doc.setDrawColor(160, 160, 160);
|
||||
doc.setLineWidth(0.8);
|
||||
doc.lines([[dr, dr], [-dr, dr], [-dr, -dr], [dr, -dr]], dcx, dcy - dr, [1, 1], 'S', true);
|
||||
doc.setFontSize(13);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(...DARK);
|
||||
doc.text(sign.signNumber || String(i + 1), dcx, dcy + 4, { align: 'center' });
|
||||
|
||||
const recText = sign.recommendation || '-';
|
||||
doc.setFontSize(14);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(160, 160, 160);
|
||||
doc.setCharSpace(1.5);
|
||||
doc.text(doc.splitTextToSize(recText, W - MARGIN - (dcx + dr + 12))[0], dcx + dr + 12, dcy + 5);
|
||||
doc.setCharSpace(0);
|
||||
|
||||
const tealY = top + dr * 2 + 8;
|
||||
doc.setDrawColor(...TEAL);
|
||||
doc.setLineWidth(1);
|
||||
doc.line(MARGIN, tealY, W - MARGIN, tealY);
|
||||
const sy = tealY + 16;
|
||||
|
||||
// ── Two-column layout ──────────────────────────────────────────────────────
|
||||
const colStart = sy;
|
||||
|
||||
|
||||
// ── Compute all positions ─────────────────────────────────────────────────
|
||||
const rcAvailH = bottom - colStart;
|
||||
const RC_LABEL_H = 16;
|
||||
const RC_PHOTO_H = (rcAvailH - RC_LABEL_H * 2) / 2;
|
||||
const existHeaderY = colStart;
|
||||
const existPhotoY = existHeaderY + RC_LABEL_H;
|
||||
const recHeaderY = existPhotoY + RC_PHOTO_H;
|
||||
const recPhotoY = recHeaderY + RC_LABEL_H;
|
||||
|
||||
const availH = bottom - colStart;
|
||||
const LABEL_H = 16;
|
||||
const DETAIL_H = 180; // 2.5"
|
||||
const GAP = 16;
|
||||
const specH = availH / 3;
|
||||
const specStartY = colStart;
|
||||
const specContentY = specStartY + LABEL_H;
|
||||
const detailHeaderY = colStart + specH + GAP;
|
||||
const detailPhotoY = detailHeaderY + LABEL_H;
|
||||
const notesStartY = detailPhotoY + DETAIL_H + GAP;
|
||||
const notesContentY = notesStartY + LABEL_H;
|
||||
|
||||
const specLines = sign.specifications ? doc.splitTextToSize(sign.specifications, textW) : [];
|
||||
const noteLines = sign.notes ? doc.splitTextToSize(sign.notes, textW) : [];
|
||||
|
||||
// ── Draw order: sign detail first, right column photos second (covers any
|
||||
// spill from sign detail masks), teal line last before text ──────────────
|
||||
await drawCover(signDetailPhotoDataUrls[i], MARGIN, detailPhotoY, textW, DETAIL_H);
|
||||
await drawCover(signExistingPhotoDataUrls[i], photoX, existPhotoY, PHOTO_W, RC_PHOTO_H);
|
||||
await drawCover(signRecommendationPhotoDataUrls[i], photoX, recPhotoY, PHOTO_W, RC_PHOTO_H);
|
||||
|
||||
// Redraw teal line on top of any photo masks
|
||||
doc.setDrawColor(...TEAL); doc.setLineWidth(1);
|
||||
doc.line(MARGIN, tealY, W - MARGIN, tealY);
|
||||
|
||||
// ── All text last so nothing gets painted over ────────────────────────────
|
||||
doc.setFontSize(10); doc.setFont('helvetica', 'bold'); doc.setTextColor(80, 80, 80);
|
||||
doc.text('Existing:', photoX, existHeaderY + 12);
|
||||
if (sign.type) {
|
||||
const lw = doc.getTextWidth('Existing:');
|
||||
doc.setFont('helvetica', 'normal'); doc.setTextColor(50, 50, 50);
|
||||
doc.text(doc.splitTextToSize(sign.type, PHOTO_W - lw - 4)[0], photoX + lw + 4, existHeaderY + 12);
|
||||
}
|
||||
doc.setFontSize(10); doc.setFont('helvetica', 'bold'); doc.setTextColor(80, 80, 80);
|
||||
doc.text('Recommendation:', photoX, recHeaderY + 12);
|
||||
if (sign.recommendation) {
|
||||
const lw = doc.getTextWidth('Recommendation:');
|
||||
doc.setFont('helvetica', 'normal'); doc.setTextColor(50, 50, 50);
|
||||
doc.text(doc.splitTextToSize(sign.recommendation, PHOTO_W - lw - 4)[0], photoX + lw + 4, recHeaderY + 12);
|
||||
}
|
||||
|
||||
doc.setFontSize(10); doc.setFont('helvetica', 'bold'); doc.setTextColor(100, 100, 100);
|
||||
doc.text('Specifications:', MARGIN, specStartY + 11);
|
||||
if (specLines.length) {
|
||||
doc.setFont('helvetica', 'normal'); doc.setTextColor(60, 60, 60);
|
||||
doc.text(specLines, MARGIN, specContentY + 11);
|
||||
}
|
||||
doc.setFontSize(10); doc.setFont('helvetica', 'bold'); doc.setTextColor(120, 120, 120);
|
||||
doc.text('Sign Detail:', MARGIN, detailHeaderY + 11);
|
||||
doc.setFontSize(10); doc.setFont('helvetica', 'bold'); doc.setTextColor(100, 100, 100);
|
||||
doc.text('Notes:', MARGIN, notesStartY + 11);
|
||||
if (noteLines.length) {
|
||||
doc.setFont('helvetica', 'italic'); doc.setTextColor(80, 80, 80);
|
||||
doc.text(noteLines, MARGIN, notesContentY + 11);
|
||||
}
|
||||
|
||||
} else {
|
||||
// ── Fourge sign page ───────────────────────────────────────────────────────
|
||||
const availH = bottom - top;
|
||||
const photoW = (W - MARGIN * 2 - 20) * 0.45;
|
||||
const specsX = MARGIN + photoW + 20;
|
||||
const specsW = W - MARGIN - specsX;
|
||||
|
||||
if (photoDataUrl) {
|
||||
const photoImg = await loadImage(photoDataUrl);
|
||||
if (photoImg) {
|
||||
const cropped = toCoverImage(photoDataUrl, photoImg, photoW, availH, 0.8, SIGN_PX_PER_PT);
|
||||
if (!cropped) continue;
|
||||
doc.setFillColor(245, 245, 245);
|
||||
doc.rect(MARGIN, top, photoW, availH, 'F');
|
||||
doc.addImage(cropped.dataUrl, cropped.format, MARGIN, top, photoW, availH);
|
||||
doc.setDrawColor(200, 200, 200);
|
||||
doc.setLineWidth(0.5);
|
||||
doc.rect(MARGIN, top, photoW, availH);
|
||||
}
|
||||
} else {
|
||||
doc.setFillColor(245, 245, 245);
|
||||
doc.rect(MARGIN, top, photoW, availH, 'F');
|
||||
doc.addImage(photoDataUrl, dx, dy, dw, dh);
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(180, 180, 180);
|
||||
doc.text('No Photo', MARGIN + photoW / 2, top + availH / 2, { align: 'center' });
|
||||
doc.setDrawColor(200, 200, 200);
|
||||
doc.setLineWidth(0.5);
|
||||
doc.rect(MARGIN, top, photoW, availH);
|
||||
}
|
||||
} else {
|
||||
doc.setFillColor(245, 245, 245);
|
||||
doc.rect(MARGIN, top, photoW, availH, 'F');
|
||||
|
||||
let sy = top;
|
||||
doc.setFillColor(...ACCENT);
|
||||
doc.roundedRect(specsX, sy, 44, 18, 3, 3, 'F');
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(180, 180, 180);
|
||||
doc.text('No Photo', MARGIN + photoW / 2, top + availH / 2, { align: 'center' });
|
||||
doc.setDrawColor(200, 200, 200);
|
||||
doc.setLineWidth(0.5);
|
||||
doc.rect(MARGIN, top, photoW, availH);
|
||||
}
|
||||
|
||||
let sy = top;
|
||||
|
||||
doc.setFillColor(...ACCENT);
|
||||
doc.roundedRect(specsX, sy, 44, 18, 3, 3, 'F');
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(...DARK);
|
||||
doc.text(`#${sign.signNumber || (i + 1)}`, specsX + 22, sy + 12, { align: 'center' });
|
||||
sy += 26;
|
||||
|
||||
doc.setFontSize(16);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(30, 30, 30);
|
||||
doc.text(sign.type || 'Sign Type', specsX, sy);
|
||||
sy += 20;
|
||||
|
||||
doc.setDrawColor(...ACCENT);
|
||||
doc.setLineWidth(1);
|
||||
doc.line(specsX, sy, specsX + specsW, sy);
|
||||
sy += 12;
|
||||
|
||||
const specs = [
|
||||
['Location', sign.location || '-'],
|
||||
['Dimensions', sign.width && sign.height ? `${sign.width}" W × ${sign.height}" H` : '-'],
|
||||
['Material', sign.material || '— (placeholder)'],
|
||||
['Illumination', sign.illumination || '— (placeholder)'],
|
||||
['Condition', sign.condition || '— (placeholder)'],
|
||||
['Mount Type', sign.mountType || '— (placeholder)'],
|
||||
];
|
||||
|
||||
specs.forEach(([label, value]) => {
|
||||
doc.setFontSize(7);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(150, 150, 150);
|
||||
doc.text(label.toUpperCase(), specsX, sy);
|
||||
sy += 9;
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(40, 40, 40);
|
||||
const wrapped = doc.splitTextToSize(value, specsW);
|
||||
doc.text(wrapped, specsX, sy);
|
||||
sy += wrapped.length * 6 + 10;
|
||||
});
|
||||
doc.setTextColor(...DARK);
|
||||
doc.text(`#${sign.signNumber || (i + 1)}`, specsX + 22, sy + 12, { align: 'center' });
|
||||
sy += 26;
|
||||
|
||||
if (sign.notes) {
|
||||
doc.setFontSize(7);
|
||||
doc.setFontSize(16);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(150, 150, 150);
|
||||
doc.text('NOTES', specsX, sy);
|
||||
sy += 9;
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(80, 80, 80);
|
||||
const noteLines = doc.splitTextToSize(sign.notes, specsW);
|
||||
doc.text(noteLines, specsX, sy);
|
||||
doc.setTextColor(30, 30, 30);
|
||||
doc.text(sign.type || 'Sign Type', specsX, sy);
|
||||
sy += 20;
|
||||
|
||||
doc.setDrawColor(...ACCENT);
|
||||
doc.setLineWidth(1);
|
||||
doc.line(specsX, sy, specsX + specsW, sy);
|
||||
sy += 12;
|
||||
|
||||
const specs = [
|
||||
['Location', sign.location || '-'],
|
||||
['Dimensions', sign.width && sign.height ? `${sign.width}" W × ${sign.height}" H` : '-'],
|
||||
['Material', sign.material || '— (placeholder)'],
|
||||
['Illumination', sign.illumination || '— (placeholder)'],
|
||||
['Condition', sign.condition || '— (placeholder)'],
|
||||
['Mount Type', sign.mountType || '— (placeholder)'],
|
||||
];
|
||||
|
||||
specs.forEach(([label, value]) => {
|
||||
doc.setFontSize(7);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(150, 150, 150);
|
||||
doc.text(label.toUpperCase(), specsX, sy);
|
||||
sy += 9;
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(40, 40, 40);
|
||||
const wrapped = doc.splitTextToSize(value, specsW);
|
||||
doc.text(wrapped, specsX, sy);
|
||||
sy += wrapped.length * 6 + 10;
|
||||
});
|
||||
|
||||
if (sign.notes) {
|
||||
doc.setFontSize(7);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(150, 150, 150);
|
||||
doc.text('NOTES', specsX, sy);
|
||||
sy += 9;
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(80, 80, 80);
|
||||
const noteLines = doc.splitTextToSize(sign.notes, specsW);
|
||||
doc.text(noteLines, specsX, sy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -690,7 +1083,8 @@ export async function generateBrandBookEditorPDF(data) {
|
||||
addFooter(doc, clientName, displayDate);
|
||||
}
|
||||
|
||||
const top = template === 'bolchoz' ? MARGIN : HEADER_H + 14;
|
||||
const SITE_LABEL_H = template === 'bolchoz' ? 18 : 0;
|
||||
const top = template === 'bolchoz' ? MARGIN + SITE_LABEL_H : HEADER_H + 14;
|
||||
const bottom = template === 'bolchoz' ? H - MARGIN - BOLCHOZ_FOOTER_H : H - 30;
|
||||
const availW = W - MARGIN * 2;
|
||||
const thumbW = (availW - gapX * (cols - 1)) / cols;
|
||||
@@ -704,34 +1098,30 @@ export async function generateBrandBookEditorPDF(data) {
|
||||
const tx = MARGIN + col * (thumbW + gapX);
|
||||
const ty = top + row * (thumbH + gapY);
|
||||
|
||||
doc.setFillColor(245, 245, 245);
|
||||
doc.rect(tx, ty, thumbW, thumbH, 'F');
|
||||
|
||||
const img = await loadImage(dataUrl);
|
||||
if (img) {
|
||||
const aspect = img.naturalWidth / img.naturalHeight;
|
||||
const boxAspect = thumbW / thumbH;
|
||||
let dw, dh, dx, dy;
|
||||
if (aspect > boxAspect) {
|
||||
dw = thumbW; dh = thumbW / aspect; dx = tx; dy = ty + (thumbH - dh) / 2;
|
||||
} else {
|
||||
dh = thumbH; dw = thumbH * aspect; dx = tx + (thumbW - dw) / 2; dy = ty;
|
||||
}
|
||||
doc.addImage(dataUrl, dx, dy, dw, dh);
|
||||
}
|
||||
await drawCover(dataUrl, tx, ty, thumbW, thumbH, { quality: 0.72, pxPerPt: THUMB_PX_PER_PT });
|
||||
doc.setDrawColor(200, 200, 200);
|
||||
doc.setLineWidth(0.3);
|
||||
doc.rect(tx, ty, thumbW, thumbH);
|
||||
}
|
||||
|
||||
// Draw header after photos so masks can't cover it
|
||||
if (template === 'bolchoz') {
|
||||
doc.setFontSize(14);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(160, 160, 160);
|
||||
doc.setCharSpace(1.5);
|
||||
doc.text('SITE PHOTOS', MARGIN, MARGIN + 10);
|
||||
doc.setCharSpace(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const safePart = (str) => (str || '').replace(/[^a-zA-Z0-9 ]/g, '').trim().replace(/\s+/g, ' ');
|
||||
const filename = [safePart(projectName), safePart(siteAddress), `R${rev}`].filter(Boolean).join('.');
|
||||
const filename = [safePart(projectName), `R${rev}`].filter(Boolean).join('.');
|
||||
doc.save(`${filename || 'BrandBook'}.pdf`);
|
||||
}
|
||||
|
||||
function drawInventoryTable(doc, signs, x, y, w, h, template) {
|
||||
function drawInventoryTable(doc, signs, x, y, w, h, template, startIndex = 0) {
|
||||
const colDefs = template === 'bolchoz'
|
||||
? [
|
||||
{ label: '#', flex: 0.5 },
|
||||
@@ -749,18 +1139,20 @@ function drawInventoryTable(doc, signs, x, y, w, h, template) {
|
||||
const cols = colDefs.map(c => ({ ...c, w: (c.flex / totalFlex) * w }));
|
||||
|
||||
const rowH = 18;
|
||||
doc.setFillColor(...DARK);
|
||||
doc.setFillColor(120, 120, 120);
|
||||
doc.rect(x, y, w, rowH, 'F');
|
||||
doc.setFontSize(7);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(...ACCENT);
|
||||
doc.setTextColor(255, 255, 255);
|
||||
|
||||
let cx = x;
|
||||
cols.forEach(col => { doc.text(col.label, cx + 5, y + 12); cx += col.w; });
|
||||
|
||||
let ry = y + rowH;
|
||||
signs.forEach((sign, i) => {
|
||||
if (ry > y + h - rowH) return;
|
||||
let nextIndex = startIndex;
|
||||
for (let i = startIndex; i < signs.length; i++) {
|
||||
if (ry + rowH > y + h) break;
|
||||
const sign = signs[i];
|
||||
const rowData = template === 'bolchoz'
|
||||
? [
|
||||
sign.signNumber || String(i + 1),
|
||||
@@ -792,7 +1184,8 @@ function drawInventoryTable(doc, signs, x, y, w, h, template) {
|
||||
doc.setLineWidth(0.2);
|
||||
doc.line(x, ry + rowH, x + w, ry + rowH);
|
||||
ry += rowH;
|
||||
});
|
||||
nextIndex = i + 1;
|
||||
}
|
||||
|
||||
doc.setDrawColor(180, 180, 180);
|
||||
doc.setLineWidth(0.5);
|
||||
@@ -804,4 +1197,6 @@ function drawInventoryTable(doc, signs, x, y, w, h, template) {
|
||||
doc.setTextColor(160, 160, 160);
|
||||
doc.text('No signs added yet', x + w / 2, y + rowH + 16, { align: 'center' });
|
||||
}
|
||||
|
||||
return nextIndex;
|
||||
}
|
||||
|
||||
@@ -1,546 +0,0 @@
|
||||
import jsPDF from 'jspdf';
|
||||
|
||||
function loadImage(src) {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => resolve(null);
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
function hexToRgb(hex) {
|
||||
const clean = hex.replace('#', '');
|
||||
const bigint = parseInt(clean, 16);
|
||||
return [(bigint >> 16) & 255, (bigint >> 8) & 255, bigint & 255];
|
||||
}
|
||||
|
||||
function isLight(hex) {
|
||||
const [r, g, b] = hexToRgb(hex);
|
||||
return (r * 299 + g * 587 + b * 114) / 1000 > 160;
|
||||
}
|
||||
|
||||
function getImgFormat(dataUrl) {
|
||||
if (!dataUrl) return 'PNG';
|
||||
if (/image\/jpe?g/i.test(dataUrl)) return 'JPEG';
|
||||
return 'PNG';
|
||||
}
|
||||
|
||||
async function toDataUrl(url) {
|
||||
const img = await loadImage(url);
|
||||
if (!img) return null;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.naturalWidth;
|
||||
canvas.height = img.naturalHeight;
|
||||
canvas.getContext('2d').drawImage(img, 0, 0);
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
function sectionHeader(doc, label, y, pageWidth) {
|
||||
doc.setFillColor(245, 165, 35);
|
||||
doc.rect(14, y, 3, 10, 'F');
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(30, 30, 30);
|
||||
doc.text(label.toUpperCase(), 21, y + 7);
|
||||
return y + 18;
|
||||
}
|
||||
|
||||
function addHeader(doc, pageWidth, logo, logoW, logoH, headerH) {
|
||||
doc.setFillColor(20, 20, 20);
|
||||
doc.rect(0, 0, pageWidth, headerH, 'F');
|
||||
if (logo) {
|
||||
doc.addImage(logo, 'PNG', 14, 6, logoW, logoH);
|
||||
} else {
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.text('FOURGE BRANDING', 14, headerH / 2 + 3);
|
||||
}
|
||||
doc.setFontSize(7);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(130, 130, 130);
|
||||
doc.text('hello@fourgebranding.com · www.fourgebranding.com', pageWidth - 14, headerH / 2 + 2, { align: 'right' });
|
||||
}
|
||||
|
||||
function addPageNumber(doc, pageNum, total, pageHeight, pageWidth) {
|
||||
doc.setFontSize(7);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(180, 180, 180);
|
||||
doc.text(`${pageNum} / ${total}`, pageWidth - 14, pageHeight - 8, { align: 'right' });
|
||||
doc.setDrawColor(230, 230, 230);
|
||||
doc.setLineWidth(0.3);
|
||||
doc.line(14, pageHeight - 13, pageWidth - 14, pageHeight - 13);
|
||||
}
|
||||
|
||||
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' });
|
||||
}
|
||||
|
||||
export async function generateBrandBookPDF(data) {
|
||||
const doc = new jsPDF();
|
||||
const pageWidth = doc.internal.pageSize.width;
|
||||
const pageHeight = doc.internal.pageSize.height;
|
||||
|
||||
// Preload Fourge logo for inner pages
|
||||
const fourgeLogoImg = await loadImage('/fourge-logo.png');
|
||||
const logoW = 36;
|
||||
const logoH = fourgeLogoImg ? (logoW / (fourgeLogoImg.naturalWidth / fourgeLogoImg.naturalHeight)) : 8;
|
||||
const headerH = logoH + 12;
|
||||
|
||||
// Pre-convert client logo URL to data URL
|
||||
let clientLogoDataUrl = null;
|
||||
if (data.clientLogoUrl) {
|
||||
clientLogoDataUrl = await toDataUrl(data.clientLogoUrl);
|
||||
}
|
||||
|
||||
const colors = (data.colors || []).filter(c => c.name || c.hex);
|
||||
const hasFonts = data.primaryFont || data.secondaryFont || data.fontNotes;
|
||||
const hasVoice = data.brandVoice || data.brandAdjectives;
|
||||
const hasLogo = data.logoNotes;
|
||||
const hasDoDont = data.dos || data.donts;
|
||||
|
||||
let totalPages = 1;
|
||||
if (data.brandStory || data.brandValues) totalPages++;
|
||||
if (colors.length > 0) totalPages++;
|
||||
if (hasFonts) totalPages++;
|
||||
if (hasVoice) totalPages++;
|
||||
if (hasLogo || hasDoDont) totalPages++;
|
||||
|
||||
let currentPage = 0;
|
||||
|
||||
// ─── PAGE 1: Cover ───────────────────────────────────────────────────────────
|
||||
currentPage++;
|
||||
|
||||
const M = 12.7; // 0.5" margin in mm
|
||||
const logoBox = 127; // 5" × 5" in mm
|
||||
const clientLogoW = 88.9; // 3.5" in mm
|
||||
const clientLogoH = 38.1; // 1.5" in mm
|
||||
|
||||
// White background
|
||||
doc.setFillColor(255, 255, 255);
|
||||
doc.rect(0, 0, pageWidth, pageHeight, 'F');
|
||||
|
||||
// ── Project logo box (top left) ──────────────────────────────────────────────
|
||||
doc.setDrawColor(210, 210, 210);
|
||||
doc.setLineWidth(0.4);
|
||||
doc.rect(M, M, logoBox, logoBox);
|
||||
|
||||
if (data.projectLogoDataUrl) {
|
||||
const pImg = await loadImage(data.projectLogoDataUrl);
|
||||
if (pImg) {
|
||||
const ratio = pImg.naturalWidth / pImg.naturalHeight;
|
||||
let dW, dH;
|
||||
if (ratio >= 1) {
|
||||
// Landscape/square: fill width first
|
||||
dW = logoBox;
|
||||
dH = dW / ratio;
|
||||
} else {
|
||||
// Portrait: fill height first
|
||||
dH = logoBox;
|
||||
dW = dH * ratio;
|
||||
}
|
||||
doc.addImage(data.projectLogoDataUrl, getImgFormat(data.projectLogoDataUrl), M, M, dW, dH);
|
||||
}
|
||||
} else {
|
||||
doc.setFontSize(8);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(210, 210, 210);
|
||||
doc.text('PROJECT LOGO', M + logoBox / 2, M + logoBox / 2, { align: 'center' });
|
||||
}
|
||||
|
||||
// ── Dates (top right) ────────────────────────────────────────────────────────
|
||||
const dateColX = M + logoBox + 10;
|
||||
let ty = M + 4;
|
||||
|
||||
doc.setFontSize(7);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(160, 160, 160);
|
||||
doc.setCharSpace(1.2);
|
||||
doc.text('CREATION DATE', dateColX, ty);
|
||||
doc.setCharSpace(0);
|
||||
ty += 6;
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(30, 30, 30);
|
||||
doc.text(formatDate(data.creationDate), dateColX, ty);
|
||||
ty += 16;
|
||||
|
||||
doc.setFontSize(7);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(160, 160, 160);
|
||||
doc.setCharSpace(1.2);
|
||||
doc.text('REVISION DATE', dateColX, ty);
|
||||
doc.setCharSpace(0);
|
||||
ty += 6;
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(30, 30, 30);
|
||||
doc.text(formatDate(data.revisionDate), dateColX, ty);
|
||||
|
||||
// ── Separator line ────────────────────────────────────────────────────────────
|
||||
const sepY = M + logoBox + 7;
|
||||
doc.setDrawColor(210, 210, 210);
|
||||
doc.setLineWidth(0.3);
|
||||
doc.line(M, sepY, pageWidth - M, sepY);
|
||||
|
||||
const botY = sepY + 10;
|
||||
const colW = (pageWidth - 2 * M - 10) / 2;
|
||||
const rightColX = M + colW + 10;
|
||||
|
||||
// ── Bottom left: Customer ─────────────────────────────────────────────────────
|
||||
let lY = botY;
|
||||
doc.setFontSize(7);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(160, 160, 160);
|
||||
doc.setCharSpace(1.2);
|
||||
doc.text('CUSTOMER', M, lY);
|
||||
doc.setCharSpace(0);
|
||||
lY += 7;
|
||||
doc.setFontSize(13);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(20, 20, 20);
|
||||
doc.text(data.customerName || '—', M, lY);
|
||||
lY += 8;
|
||||
if (data.streetAddress) {
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(80, 80, 80);
|
||||
const addrLines = doc.splitTextToSize(data.streetAddress, colW);
|
||||
doc.text(addrLines, M, lY);
|
||||
}
|
||||
|
||||
// ── Bottom right: Signature / Approval ───────────────────────────────────────
|
||||
let rY = botY;
|
||||
|
||||
// Signature of approval
|
||||
doc.setFontSize(7);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(160, 160, 160);
|
||||
doc.setCharSpace(1.2);
|
||||
doc.text('SIGNATURE OF APPROVAL', rightColX, rY);
|
||||
doc.setCharSpace(0);
|
||||
rY += 12;
|
||||
doc.setDrawColor(180, 180, 180);
|
||||
doc.setLineWidth(0.3);
|
||||
doc.line(rightColX, rY, pageWidth - M, rY);
|
||||
rY += 14;
|
||||
|
||||
// Approved date
|
||||
doc.setFontSize(7);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(160, 160, 160);
|
||||
doc.setCharSpace(1.2);
|
||||
doc.text('APPROVED DATE', rightColX, rY);
|
||||
doc.setCharSpace(0);
|
||||
rY += 6;
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(30, 30, 30);
|
||||
doc.text(formatDate(data.approvedDate), rightColX, rY);
|
||||
rY += 14;
|
||||
|
||||
// Notes
|
||||
doc.setFontSize(7);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(160, 160, 160);
|
||||
doc.setCharSpace(1.2);
|
||||
doc.text('NOTES', rightColX, rY);
|
||||
doc.setCharSpace(0);
|
||||
rY += 6;
|
||||
if (data.approvalNotes) {
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(60, 60, 60);
|
||||
const noteLines = doc.splitTextToSize(data.approvalNotes, colW);
|
||||
doc.text(noteLines, rightColX, rY);
|
||||
}
|
||||
|
||||
// ── Client logo + contact (right-bottom aligned) ──────────────────────────────
|
||||
// Contact text (right-aligned), bottom of page
|
||||
const contactLineH = 5.5;
|
||||
const hasContact = data.clientContactName || data.clientContactEmail || data.clientContactPhone;
|
||||
const contactLines = [data.clientContactName, data.clientContactEmail, data.clientContactPhone].filter(Boolean);
|
||||
const contactBlockH = contactLines.length * contactLineH;
|
||||
const contactStartY = pageHeight - M - contactBlockH;
|
||||
|
||||
if (contactLines.length > 0) {
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(60, 60, 60);
|
||||
let cy = contactStartY + 4;
|
||||
if (data.clientContactName) {
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(data.clientContactName, pageWidth - M, cy, { align: 'right' });
|
||||
cy += contactLineH;
|
||||
}
|
||||
doc.setFont('helvetica', 'normal');
|
||||
if (data.clientContactEmail) { doc.text(data.clientContactEmail, pageWidth - M, cy, { align: 'right' }); cy += contactLineH; }
|
||||
if (data.clientContactPhone) { doc.text(data.clientContactPhone, pageWidth - M, cy, { align: 'right' }); }
|
||||
}
|
||||
|
||||
// Client logo box: 3.5" × 1.5", right-bottom, above contact text
|
||||
const logoBoxGap = hasContact ? contactBlockH + 5 : 4;
|
||||
const clientLogoBoxBottom = pageHeight - M - (hasContact ? contactBlockH + 6 : 2);
|
||||
const clientLogoBoxTop = clientLogoBoxBottom - clientLogoH;
|
||||
const clientLogoBoxLeft = pageWidth - M - clientLogoW;
|
||||
|
||||
doc.setDrawColor(210, 210, 210);
|
||||
doc.setLineWidth(0.3);
|
||||
doc.rect(clientLogoBoxLeft, clientLogoBoxTop, clientLogoW, clientLogoH);
|
||||
|
||||
if (clientLogoDataUrl) {
|
||||
const clImg = await loadImage(clientLogoDataUrl);
|
||||
if (clImg) {
|
||||
const ratio = clImg.naturalWidth / clImg.naturalHeight;
|
||||
// Scale to contain within box
|
||||
let dW = clientLogoW, dH = clientLogoH;
|
||||
if (ratio > clientLogoW / clientLogoH) {
|
||||
dW = clientLogoW;
|
||||
dH = dW / ratio;
|
||||
} else {
|
||||
dH = clientLogoH;
|
||||
dW = dH * ratio;
|
||||
}
|
||||
// Center in box
|
||||
const ox = clientLogoBoxLeft + (clientLogoW - dW) / 2;
|
||||
const oy = clientLogoBoxTop + (clientLogoH - dH) / 2;
|
||||
doc.addImage(clientLogoDataUrl, 'PNG', ox, oy, dW, dH);
|
||||
}
|
||||
} else if (!hasContact) {
|
||||
doc.setFontSize(8);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(210, 210, 210);
|
||||
doc.text('CLIENT LOGO', clientLogoBoxLeft + clientLogoW / 2, clientLogoBoxTop + clientLogoH / 2, { align: 'center' });
|
||||
}
|
||||
|
||||
// ─── PAGE 2: Brand Story + Values ────────────────────────────────────────────
|
||||
if (data.brandStory || data.brandValues) {
|
||||
currentPage++;
|
||||
doc.addPage();
|
||||
addHeader(doc, pageWidth, fourgeLogoImg, logoW, logoH, headerH);
|
||||
let y = headerH + 16;
|
||||
|
||||
if (data.brandStory) {
|
||||
y = sectionHeader(doc, 'Brand Story', y, pageWidth);
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(60, 60, 60);
|
||||
const lines = doc.splitTextToSize(data.brandStory, pageWidth - 28);
|
||||
doc.text(lines, 14, y);
|
||||
y += lines.length * 6 + 16;
|
||||
}
|
||||
|
||||
if (data.brandValues) {
|
||||
y = sectionHeader(doc, 'Brand Values', y, pageWidth);
|
||||
const values = data.brandValues.split('\n').map(v => v.trim()).filter(Boolean);
|
||||
values.forEach(val => {
|
||||
doc.setFillColor(245, 165, 35);
|
||||
doc.circle(17, y - 1, 1.2, 'F');
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(50, 50, 50);
|
||||
doc.text(val, 22, y);
|
||||
y += 8;
|
||||
});
|
||||
}
|
||||
|
||||
addPageNumber(doc, currentPage, totalPages, pageHeight, pageWidth);
|
||||
}
|
||||
|
||||
// ─── PAGE 3: Color Palette ────────────────────────────────────────────────────
|
||||
if (colors.length > 0) {
|
||||
currentPage++;
|
||||
doc.addPage();
|
||||
addHeader(doc, pageWidth, fourgeLogoImg, logoW, logoH, headerH);
|
||||
let y = headerH + 16;
|
||||
y = sectionHeader(doc, 'Color Palette', y, pageWidth);
|
||||
|
||||
const swatchW = 52;
|
||||
const swatchH = 40;
|
||||
const cols = 3;
|
||||
const gapX = (pageWidth - 28 - swatchW * cols) / (cols - 1);
|
||||
|
||||
colors.forEach((color, i) => {
|
||||
const col = i % cols;
|
||||
const row = Math.floor(i / cols);
|
||||
const x = 14 + col * (swatchW + gapX);
|
||||
const sy = y + row * (swatchH + 22);
|
||||
|
||||
const rgb = hexToRgb(color.hex || '#cccccc');
|
||||
doc.setFillColor(...rgb);
|
||||
doc.roundedRect(x, sy, swatchW, swatchH, 3, 3, 'F');
|
||||
|
||||
doc.setFontSize(7);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(...(isLight(color.hex || '#cccccc') ? [60, 60, 60] : [220, 220, 220]));
|
||||
doc.text((color.hex || '').toUpperCase(), x + swatchW / 2, sy + swatchH - 5, { align: 'center' });
|
||||
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(40, 40, 40);
|
||||
doc.text(color.name || 'Unnamed', x + swatchW / 2, sy + swatchH + 8, { align: 'center' });
|
||||
});
|
||||
|
||||
addPageNumber(doc, currentPage, totalPages, pageHeight, pageWidth);
|
||||
}
|
||||
|
||||
// ─── PAGE 4: Typography ───────────────────────────────────────────────────────
|
||||
if (hasFonts) {
|
||||
currentPage++;
|
||||
doc.addPage();
|
||||
addHeader(doc, pageWidth, fourgeLogoImg, logoW, logoH, headerH);
|
||||
let y = headerH + 16;
|
||||
y = sectionHeader(doc, 'Typography', y, pageWidth);
|
||||
|
||||
const fontItems = [
|
||||
data.primaryFont && ['Primary Font', data.primaryFont],
|
||||
data.secondaryFont && ['Secondary Font', data.secondaryFont],
|
||||
].filter(Boolean);
|
||||
|
||||
fontItems.forEach(([label, fontName]) => {
|
||||
doc.setFillColor(248, 248, 248);
|
||||
doc.setDrawColor(220, 220, 220);
|
||||
doc.setLineWidth(0.3);
|
||||
doc.roundedRect(14, y, pageWidth - 28, 28, 3, 3, 'FD');
|
||||
|
||||
doc.setFontSize(7);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(150, 150, 150);
|
||||
doc.text(label.toUpperCase(), 20, y + 8);
|
||||
|
||||
doc.setFontSize(16);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(30, 30, 30);
|
||||
doc.text(fontName, 20, y + 21);
|
||||
|
||||
y += 36;
|
||||
});
|
||||
|
||||
if (data.fontNotes) {
|
||||
y += 4;
|
||||
y = sectionHeader(doc, 'Usage Notes', y, pageWidth);
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(60, 60, 60);
|
||||
const lines = doc.splitTextToSize(data.fontNotes, pageWidth - 28);
|
||||
doc.text(lines, 14, y);
|
||||
}
|
||||
|
||||
addPageNumber(doc, currentPage, totalPages, pageHeight, pageWidth);
|
||||
}
|
||||
|
||||
// ─── PAGE 5: Brand Voice ──────────────────────────────────────────────────────
|
||||
if (hasVoice) {
|
||||
currentPage++;
|
||||
doc.addPage();
|
||||
addHeader(doc, pageWidth, fourgeLogoImg, logoW, logoH, headerH);
|
||||
let y = headerH + 16;
|
||||
y = sectionHeader(doc, 'Brand Voice & Tone', y, pageWidth);
|
||||
|
||||
if (data.brandVoice) {
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(60, 60, 60);
|
||||
const lines = doc.splitTextToSize(data.brandVoice, pageWidth - 28);
|
||||
doc.text(lines, 14, y);
|
||||
y += lines.length * 6 + 16;
|
||||
}
|
||||
|
||||
if (data.brandAdjectives) {
|
||||
y = sectionHeader(doc, 'Brand Personality', y, pageWidth);
|
||||
const tags = data.brandAdjectives.split(',').map(t => t.trim()).filter(Boolean);
|
||||
let tx = 14;
|
||||
tags.forEach(tag => {
|
||||
const tw = doc.getTextWidth(tag) + 10;
|
||||
if (tx + tw > pageWidth - 14) { tx = 14; y += 14; }
|
||||
doc.setFillColor(255, 243, 215);
|
||||
doc.setDrawColor(245, 165, 35);
|
||||
doc.setLineWidth(0.3);
|
||||
doc.roundedRect(tx, y - 6, tw, 10, 2, 2, 'FD');
|
||||
doc.setFontSize(8);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(150, 100, 20);
|
||||
doc.text(tag, tx + 5, y + 1);
|
||||
tx += tw + 5;
|
||||
});
|
||||
}
|
||||
|
||||
addPageNumber(doc, currentPage, totalPages, pageHeight, pageWidth);
|
||||
}
|
||||
|
||||
// ─── PAGE 6: Logo + Do's & Don'ts ────────────────────────────────────────────
|
||||
if (hasLogo || hasDoDont) {
|
||||
currentPage++;
|
||||
doc.addPage();
|
||||
addHeader(doc, pageWidth, fourgeLogoImg, logoW, logoH, headerH);
|
||||
let y = headerH + 16;
|
||||
|
||||
if (hasLogo) {
|
||||
y = sectionHeader(doc, 'Logo Usage Guidelines', y, pageWidth);
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(60, 60, 60);
|
||||
const lines = doc.splitTextToSize(data.logoNotes, pageWidth - 28);
|
||||
doc.text(lines, 14, y);
|
||||
y += lines.length * 6 + 16;
|
||||
}
|
||||
|
||||
if (hasDoDont) {
|
||||
const colW = (pageWidth - 28 - 8) / 2;
|
||||
|
||||
if (data.dos) {
|
||||
doc.setFillColor(22, 163, 74);
|
||||
doc.rect(14, y, 3, 10, 'F');
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(22, 163, 74);
|
||||
doc.text("DO'S", 21, y + 7);
|
||||
|
||||
const dosLines = data.dos.split('\n').map(l => l.trim()).filter(Boolean);
|
||||
let dy = y + 18;
|
||||
dosLines.forEach(line => {
|
||||
doc.setFillColor(22, 163, 74);
|
||||
doc.circle(16, dy - 1, 1.2, 'F');
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(50, 50, 50);
|
||||
const wrapped = doc.splitTextToSize(line, colW - 10);
|
||||
doc.text(wrapped, 21, dy);
|
||||
dy += wrapped.length * 5.5 + 3;
|
||||
});
|
||||
}
|
||||
|
||||
if (data.donts) {
|
||||
const startX = 14 + colW + 8;
|
||||
doc.setFillColor(220, 38, 38);
|
||||
doc.rect(startX, y, 3, 10, 'F');
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(220, 38, 38);
|
||||
doc.text("DON'TS", startX + 7, y + 7);
|
||||
|
||||
const dontsLines = data.donts.split('\n').map(l => l.trim()).filter(Boolean);
|
||||
let dy = y + 18;
|
||||
dontsLines.forEach(line => {
|
||||
doc.setFillColor(220, 38, 38);
|
||||
doc.circle(startX + 2, dy - 1, 1.2, 'F');
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(50, 50, 50);
|
||||
const wrapped = doc.splitTextToSize(line, colW - 10);
|
||||
doc.text(wrapped, startX + 7, dy);
|
||||
dy += wrapped.length * 5.5 + 3;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
addPageNumber(doc, currentPage, totalPages, pageHeight, pageWidth);
|
||||
}
|
||||
|
||||
const safeName = (data.brandName || 'brand-book').toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
||||
doc.save(`${safeName}-brand-book.pdf`);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
export function formatDateEST(value) {
|
||||
if (!value) return '—';
|
||||
return new Date(value).toLocaleDateString('en-US', {
|
||||
timeZone: 'America/New_York',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
export function parseDateOnly(value) {
|
||||
if (!value) return null;
|
||||
const [year, month, day] = String(value).split('-').map(Number);
|
||||
if (!year || !month || !day) return null;
|
||||
return new Date(year, month - 1, day);
|
||||
}
|
||||
|
||||
export function getTodayDateOnlyEST() {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'America/New_York',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).formatToParts(new Date());
|
||||
const year = parts.find((part) => part.type === 'year')?.value;
|
||||
const month = parts.find((part) => part.type === 'month')?.value;
|
||||
const day = parts.find((part) => part.type === 'day')?.value;
|
||||
return year && month && day ? `${year}-${month}-${day}` : '';
|
||||
}
|
||||
|
||||
export function addDaysToDateOnly(value, days) {
|
||||
const date = parseDateOnly(value);
|
||||
if (!date) return '';
|
||||
date.setDate(date.getDate() + days);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export function formatDateOnly(value, fallback = '—') {
|
||||
const date = parseDateOnly(value);
|
||||
if (!date) return fallback;
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,45 @@
|
||||
import { supabase } from './supabase';
|
||||
|
||||
async function removeFromBucket(bucket, paths) {
|
||||
const uniquePaths = [...new Set((paths || []).filter(Boolean))];
|
||||
if (!uniquePaths.length) return;
|
||||
|
||||
const { error } = await supabase.storage.from(bucket).remove(uniquePaths);
|
||||
if (error) {
|
||||
throw new Error(`Failed to delete files from ${bucket}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function getBrandBookStoragePaths(book) {
|
||||
if (!book) return [];
|
||||
|
||||
const signPaths = (book.signs || []).flatMap(sign => [
|
||||
sign.photoPath,
|
||||
sign.existingPhotoPath,
|
||||
sign.recommendationPhotoPath,
|
||||
sign.signDetailPhotoPath,
|
||||
]);
|
||||
|
||||
return [
|
||||
book.site_map_path,
|
||||
book.inventory_map_path,
|
||||
book.project_logo_path,
|
||||
...(book.survey_photo_paths || []),
|
||||
...signPaths,
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
export function getCompanyLogoStoragePath(publicUrl) {
|
||||
if (!publicUrl || typeof publicUrl !== 'string') return null;
|
||||
|
||||
const marker = '/storage/v1/object/public/company-logos/';
|
||||
const idx = publicUrl.indexOf(marker);
|
||||
if (idx === -1) return null;
|
||||
|
||||
const path = publicUrl.slice(idx + marker.length);
|
||||
return path ? decodeURIComponent(path) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all storage files (submissions + deliveries buckets) for the given task IDs.
|
||||
* Call this before deleting tasks/projects/companies from the DB.
|
||||
@@ -24,7 +64,7 @@ export async function cleanupTaskStorage(taskIds) {
|
||||
.in('submission_id', subIds);
|
||||
|
||||
const subPaths = (subFiles || []).map(f => f.storage_path).filter(Boolean);
|
||||
if (subPaths.length) await supabase.storage.from('submissions').remove(subPaths);
|
||||
await removeFromBucket('submissions', subPaths);
|
||||
|
||||
// Get deliveries (linked via submission_id, not task_id)
|
||||
const { data: deliveries } = await supabase
|
||||
@@ -41,7 +81,51 @@ export async function cleanupTaskStorage(taskIds) {
|
||||
.in('delivery_id', delIds);
|
||||
|
||||
const delPaths = (delFiles || []).map(f => f.storage_path).filter(Boolean);
|
||||
if (delPaths.length) await supabase.storage.from('deliveries').remove(delPaths);
|
||||
await removeFromBucket('deliveries', delPaths);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes brand book storage assets from the `brand-books` bucket.
|
||||
* Call this before deleting the brand_books row from the DB.
|
||||
*/
|
||||
export async function cleanupBrandBookStorage(book) {
|
||||
if (!book) return;
|
||||
const paths = getBrandBookStoragePaths(book);
|
||||
await removeFromBucket('brand-books', paths);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a whole company footprint, including brand books and logo storage.
|
||||
* Client/external profiles are preserved by the DB and simply become unassigned.
|
||||
*/
|
||||
export async function deleteCompanyData(companyId) {
|
||||
if (!companyId) return;
|
||||
|
||||
const [{ data: company }, { data: projects }, { data: brandBooks }] = await Promise.all([
|
||||
supabase.from('companies').select('id, client_logo_url').eq('id', companyId).single(),
|
||||
supabase.from('projects').select('id').eq('company_id', companyId),
|
||||
supabase.from('brand_books').select('*').eq('client_id', companyId),
|
||||
]);
|
||||
|
||||
const projectIds = (projects || []).map(project => project.id);
|
||||
if (projectIds.length) {
|
||||
const { data: tasks } = await supabase.from('tasks').select('id').in('project_id', projectIds);
|
||||
const taskIds = (tasks || []).map(task => task.id);
|
||||
await cleanupTaskStorage(taskIds);
|
||||
}
|
||||
|
||||
for (const book of brandBooks || []) {
|
||||
await cleanupBrandBookStorage(book);
|
||||
}
|
||||
|
||||
if (brandBooks?.length) {
|
||||
await supabase.from('brand_books').delete().eq('client_id', companyId);
|
||||
}
|
||||
|
||||
const logoPath = getCompanyLogoStoragePath(company?.client_logo_url);
|
||||
await removeFromBucket('company-logos', logoPath ? [logoPath] : []);
|
||||
|
||||
await supabase.from('companies').delete().eq('id', companyId);
|
||||
}
|
||||
|
||||
Executable → Regular
+85
-13
@@ -1,16 +1,88 @@
|
||||
import { supabase } from './supabase';
|
||||
|
||||
export async function sendEmail(type, to, data) {
|
||||
const { data: result, error } = await supabase.functions.invoke('send-email', {
|
||||
body: { type, to, data },
|
||||
});
|
||||
if (error) {
|
||||
console.error('Email invoke error:', error);
|
||||
throw new Error(`Email failed: ${error.message || JSON.stringify(error)}`);
|
||||
}
|
||||
if (result?.error) {
|
||||
console.error('Email send error:', result.error);
|
||||
throw new Error(`Email failed: ${JSON.stringify(result.error)}`);
|
||||
}
|
||||
return result;
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
||||
|
||||
async function getAccessToken() {
|
||||
const { data: sessionData } = await supabase.auth.getSession();
|
||||
if (sessionData?.session?.access_token) return sessionData.session.access_token;
|
||||
|
||||
const { data: refreshed, error: refreshError } = await supabase.auth.refreshSession();
|
||||
if (refreshError) throw new Error(refreshError.message || 'Failed to refresh session');
|
||||
if (!refreshed?.session?.access_token) throw new Error('No active session');
|
||||
return refreshed.session.access_token;
|
||||
}
|
||||
|
||||
export function blobToEmailAttachment(blob, filename) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const result = String(reader.result || '');
|
||||
resolve({
|
||||
filename,
|
||||
content: result.includes(',') ? result.split(',')[1] : result,
|
||||
});
|
||||
};
|
||||
reader.onerror = () => reject(new Error(`Failed to encode ${filename}`));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
async function postEmailRequest(type, to, data, token, attachments = []) {
|
||||
const response = await fetch(`${supabaseUrl}/functions/v1/send-email`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
apikey: supabaseAnonKey,
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ type, to, data, attachments }),
|
||||
});
|
||||
|
||||
const raw = await response.text();
|
||||
let body = null;
|
||||
|
||||
if (raw) {
|
||||
try {
|
||||
body = JSON.parse(raw);
|
||||
} catch {
|
||||
body = { raw };
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = body?.error || body?.message || raw || `HTTP ${response.status}`;
|
||||
throw new Error(String(detail));
|
||||
}
|
||||
|
||||
if (body?.error) {
|
||||
throw new Error(typeof body.error === 'string' ? body.error : JSON.stringify(body.error));
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function sendEmail(type, to, data, attachments = []) {
|
||||
const token = await getAccessToken();
|
||||
|
||||
try {
|
||||
return await postEmailRequest(type, to, data, token, attachments);
|
||||
} catch (error) {
|
||||
if (!/invalid jwt/i.test(error?.message || '')) {
|
||||
console.error('Email request failed:', error);
|
||||
throw new Error(`Email failed: ${error?.message || 'Unknown email error'}`);
|
||||
}
|
||||
|
||||
const { data: refreshed, error: refreshError } = await supabase.auth.refreshSession();
|
||||
if (refreshError || !refreshed?.session?.access_token) {
|
||||
throw new Error(`Email failed: ${refreshError?.message || error.message}`);
|
||||
}
|
||||
|
||||
try {
|
||||
return await postEmailRequest(type, to, data, refreshed.session.access_token, attachments);
|
||||
} catch (retryError) {
|
||||
console.error('Email retry failed:', retryError);
|
||||
throw new Error(`Email failed: ${retryError?.message || 'Unknown email error'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1023
-139
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
const PREFIX = 'fourge_page_cache:';
|
||||
|
||||
export function readPageCache(key, maxAgeMs = 60_000) {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(PREFIX + key);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed?.savedAt || Date.now() - parsed.savedAt > maxAgeMs) return null;
|
||||
return parsed.data ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writePageCache(key, data) {
|
||||
try {
|
||||
sessionStorage.setItem(PREFIX + key, JSON.stringify({
|
||||
savedAt: Date.now(),
|
||||
data,
|
||||
}));
|
||||
} catch {
|
||||
// Ignore cache write failures.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { supabase } from './supabase';
|
||||
|
||||
function normalizeProjectName(name = '') {
|
||||
return String(name).trim().toLowerCase();
|
||||
}
|
||||
|
||||
export async function findOrCreateProject(companyId, projectName, knownProjects = []) {
|
||||
const normalized = normalizeProjectName(projectName);
|
||||
if (!companyId || !normalized) throw new Error('Project company and name are required.');
|
||||
|
||||
const existingKnown = knownProjects.find(project => normalizeProjectName(project.name) === normalized);
|
||||
if (existingKnown) return existingKnown;
|
||||
|
||||
const { data: candidateProjects, error: lookupError } = await supabase
|
||||
.from('projects')
|
||||
.select('id, name, company_id, status')
|
||||
.eq('company_id', companyId)
|
||||
.ilike('name', projectName.trim());
|
||||
if (lookupError) throw lookupError;
|
||||
|
||||
const matched = (candidateProjects || []).find(project => normalizeProjectName(project.name) === normalized);
|
||||
if (matched) return matched;
|
||||
|
||||
const { data: newProject, error: insertError } = await supabase
|
||||
.from('projects')
|
||||
.insert({
|
||||
company_id: companyId,
|
||||
name: projectName.trim(),
|
||||
status: 'active',
|
||||
})
|
||||
.select('id, name, company_id, status')
|
||||
.single();
|
||||
|
||||
if (!insertError && newProject) return newProject;
|
||||
|
||||
if (insertError?.code === '23505') {
|
||||
const { data: retryProjects, error: retryError } = await supabase
|
||||
.from('projects')
|
||||
.select('id, name, company_id, status')
|
||||
.eq('company_id', companyId)
|
||||
.ilike('name', projectName.trim());
|
||||
if (retryError) throw retryError;
|
||||
const retried = (retryProjects || []).find(project => normalizeProjectName(project.name) === normalized);
|
||||
if (retried) return retried;
|
||||
}
|
||||
|
||||
throw insertError || new Error('Failed to create project.');
|
||||
}
|
||||
|
||||
export async function createTaskForRequest({ projectId, title, requestKey }) {
|
||||
const { data: task, error } = await supabase
|
||||
.from('tasks')
|
||||
.insert({
|
||||
project_id: projectId,
|
||||
title,
|
||||
status: 'not_started',
|
||||
current_version: 0,
|
||||
request_key: requestKey,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (!error && task) return { task, duplicate: false };
|
||||
|
||||
if (error?.code === '23505' && requestKey) {
|
||||
const { data: existingTask, error: existingError } = await supabase
|
||||
.from('tasks')
|
||||
.select('*')
|
||||
.eq('request_key', requestKey)
|
||||
.single();
|
||||
if (existingError) throw existingError;
|
||||
if (existingTask) return { task: existingTask, duplicate: true };
|
||||
}
|
||||
|
||||
throw error || new Error('Failed to create task.');
|
||||
}
|
||||
|
||||
export async function createInitialSubmissionForRequest({
|
||||
taskId,
|
||||
requestKey,
|
||||
isHot,
|
||||
serviceType,
|
||||
deadline,
|
||||
description,
|
||||
submittedBy,
|
||||
submittedByName,
|
||||
}) {
|
||||
const { data: submission, error } = await supabase
|
||||
.from('submissions')
|
||||
.insert({
|
||||
task_id: taskId,
|
||||
request_key: requestKey,
|
||||
version_number: 0,
|
||||
type: 'initial',
|
||||
is_hot: isHot,
|
||||
service_type: serviceType,
|
||||
deadline: deadline || null,
|
||||
description,
|
||||
submitted_by: submittedBy,
|
||||
submitted_by_name: submittedByName,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (!error && submission) return { submission, duplicate: false };
|
||||
|
||||
if (error?.code === '23505' && requestKey) {
|
||||
const { data: existingSubmission, error: existingError } = await supabase
|
||||
.from('submissions')
|
||||
.select('*')
|
||||
.eq('request_key', requestKey)
|
||||
.single();
|
||||
if (existingError) throw existingError;
|
||||
if (existingSubmission) return { submission: existingSubmission, duplicate: true };
|
||||
}
|
||||
|
||||
throw error || new Error('Failed to create submission.');
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { supabase } from './supabase';
|
||||
|
||||
export async function syncSeafileFolders() {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session?.access_token) return { skipped: true };
|
||||
|
||||
const response = await fetch('/api/seafile?action=sync-folders', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.error || 'Failed to sync Seafile folders.');
|
||||
return data;
|
||||
}
|
||||
@@ -1,441 +0,0 @@
|
||||
import jsPDF from 'jspdf';
|
||||
|
||||
// Letter landscape: 792 x 612 pt
|
||||
const W = 792;
|
||||
const H = 612;
|
||||
const MARGIN = 36;
|
||||
const ACCENT = [245, 165, 35];
|
||||
const DARK = [18, 18, 18];
|
||||
const HEADER_H = 32;
|
||||
|
||||
// Accepts File, data URL string, or https URL string — returns data URL
|
||||
async function resolvePhoto(source) {
|
||||
if (!source) return null;
|
||||
if (source instanceof File) {
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => resolve(e.target.result);
|
||||
reader.onerror = () => resolve(null);
|
||||
reader.readAsDataURL(source);
|
||||
});
|
||||
}
|
||||
if (typeof source === 'string') {
|
||||
if (source.startsWith('data:')) return source;
|
||||
try {
|
||||
const resp = await fetch(source);
|
||||
const blob = await resp.blob();
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => resolve(e.target.result);
|
||||
reader.onerror = () => resolve(null);
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
} catch { return null; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function loadImage(src) {
|
||||
return new Promise((resolve) => {
|
||||
if (!src) return resolve(null);
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => resolve(null);
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
function addHeader(doc, logo, logoW, logoH, title, pageNum, totalPages) {
|
||||
doc.setFillColor(...DARK);
|
||||
doc.rect(0, 0, W, HEADER_H, 'F');
|
||||
doc.setFillColor(...ACCENT);
|
||||
doc.rect(0, 0, 4, HEADER_H, 'F');
|
||||
|
||||
if (logo) {
|
||||
doc.addImage(logo, 'PNG', MARGIN, (HEADER_H - logoH) / 2, logoW, logoH);
|
||||
} else {
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.text('FOURGE BRANDING', MARGIN, HEADER_H / 2 + 3);
|
||||
}
|
||||
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(200, 200, 200);
|
||||
doc.text(title.toUpperCase(), W / 2, HEADER_H / 2 + 3, { align: 'center' });
|
||||
|
||||
doc.setFontSize(8);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(120, 120, 120);
|
||||
doc.text(`${pageNum} / ${totalPages}`, W - MARGIN, HEADER_H / 2 + 3, { align: 'right' });
|
||||
}
|
||||
|
||||
function addFooter(doc, clientName, date) {
|
||||
doc.setDrawColor(60, 60, 60);
|
||||
doc.setLineWidth(0.3);
|
||||
doc.line(MARGIN, H - 22, W - MARGIN, H - 22);
|
||||
doc.setFontSize(7);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(130, 130, 130);
|
||||
doc.text(clientName, MARGIN, H - 12);
|
||||
doc.text('hello@fourgebranding.com · www.fourgebranding.com', W / 2, H - 12, { align: 'center' });
|
||||
if (date) doc.text(date, W - MARGIN, H - 12, { align: 'right' });
|
||||
}
|
||||
|
||||
export async function generateBrandBookEditorPDF(data) {
|
||||
const { clientName, projectName, siteAddress, bookDate, preparedBy, revision,
|
||||
siteMapSource, signs, surveyPhotoSources } = data;
|
||||
|
||||
const doc = new jsPDF({ orientation: 'landscape', format: 'letter', unit: 'pt' });
|
||||
|
||||
// Load assets
|
||||
const logo = await loadImage('/fourge-logo.png');
|
||||
const logoW = 40;
|
||||
const logoH = logo ? (logoW / (logo.naturalWidth / logo.naturalHeight)) : 10;
|
||||
|
||||
// Resolve all photos to data URLs up front
|
||||
const siteMapDataUrl = await resolvePhoto(siteMapSource);
|
||||
const signPhotoDataUrls = await Promise.all(signs.map(s => resolvePhoto(s.photoSource)));
|
||||
const surveyPhotoDataUrls = await Promise.all((surveyPhotoSources || []).map(s => resolvePhoto(s)));
|
||||
|
||||
// Count pages
|
||||
let totalPages = 1; // cover
|
||||
totalPages++; // sign inventory
|
||||
totalPages += signs.length;
|
||||
if (surveyPhotoDataUrls.some(Boolean)) totalPages++;
|
||||
|
||||
let pageNum = 0;
|
||||
const displayDate = bookDate
|
||||
? new Date(bookDate + 'T12:00:00').toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
|
||||
: '';
|
||||
|
||||
// ─── COVER PAGE ──────────────────────────────────────────────────────────────
|
||||
pageNum++;
|
||||
doc.setFillColor(...DARK);
|
||||
doc.rect(0, 0, W, H, 'F');
|
||||
doc.setFillColor(...ACCENT);
|
||||
doc.rect(0, 0, W, 4, 'F');
|
||||
doc.rect(0, H - 4, W, 4, 'F');
|
||||
|
||||
if (logo) {
|
||||
doc.addImage(logo, 'PNG', MARGIN, 22, logoW, logoH);
|
||||
} else {
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.text('FOURGE BRANDING', MARGIN, 38);
|
||||
}
|
||||
|
||||
const cy = H / 2;
|
||||
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(...ACCENT);
|
||||
doc.setCharSpace(3);
|
||||
doc.text('BRAND BOOK', W / 2, cy - 54, { align: 'center' });
|
||||
doc.setCharSpace(0);
|
||||
|
||||
doc.setDrawColor(...ACCENT);
|
||||
doc.setLineWidth(0.5);
|
||||
doc.line(W / 2 - 40, cy - 46, W / 2 + 40, cy - 46);
|
||||
|
||||
doc.setFontSize(36);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.text(clientName || 'Client Name', W / 2, cy - 18, { align: 'center' });
|
||||
|
||||
if (projectName) {
|
||||
doc.setFontSize(14);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(160, 160, 160);
|
||||
doc.text(projectName, W / 2, cy + 12, { align: 'center' });
|
||||
}
|
||||
|
||||
const metaParts = [];
|
||||
if (siteAddress) metaParts.push(siteAddress);
|
||||
if (displayDate) metaParts.push(displayDate);
|
||||
if (preparedBy) metaParts.push(`Prepared by ${preparedBy}`);
|
||||
if (metaParts.length > 0) {
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(100, 100, 100);
|
||||
doc.text(metaParts.join(' · '), W / 2, cy + 36, { align: 'center' });
|
||||
}
|
||||
|
||||
// Revision badge bottom right of cover
|
||||
const rev = String(revision || '01').padStart(2, '0');
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(80, 80, 80);
|
||||
doc.text(`R${rev}`, W - MARGIN, cy + 36, { align: 'right' });
|
||||
|
||||
// ─── PAGE 2: SIGN INVENTORY ───────────────────────────────────────────────────
|
||||
pageNum++;
|
||||
doc.addPage();
|
||||
addHeader(doc, logo, logoW, logoH, 'Sign Inventory', pageNum, totalPages);
|
||||
addFooter(doc, clientName, displayDate);
|
||||
|
||||
const contentTop = HEADER_H + 16;
|
||||
const contentBottom = H - 30;
|
||||
const contentH = contentBottom - contentTop;
|
||||
|
||||
if (siteMapDataUrl) {
|
||||
const mapW = (W - MARGIN * 2 - 16) * 0.55;
|
||||
const mapH = contentH;
|
||||
const mapX = MARGIN;
|
||||
const mapY = contentTop;
|
||||
|
||||
const siteImg = await loadImage(siteMapDataUrl);
|
||||
if (siteImg) {
|
||||
const imgAspect = siteImg.naturalWidth / siteImg.naturalHeight;
|
||||
const boxAspect = mapW / mapH;
|
||||
let drawW, drawH, drawX, drawY;
|
||||
if (imgAspect > boxAspect) {
|
||||
drawW = mapW; drawH = mapW / imgAspect;
|
||||
drawX = mapX; drawY = mapY + (mapH - drawH) / 2;
|
||||
} else {
|
||||
drawH = mapH; drawW = mapH * imgAspect;
|
||||
drawX = mapX + (mapW - drawW) / 2; drawY = mapY;
|
||||
}
|
||||
doc.setDrawColor(80, 80, 80);
|
||||
doc.setLineWidth(0.5);
|
||||
doc.rect(mapX, mapY, mapW, mapH);
|
||||
doc.addImage(siteMapDataUrl, drawX, drawY, drawW, drawH);
|
||||
}
|
||||
|
||||
const tableX = MARGIN + mapW + 16;
|
||||
const tableW = W - MARGIN - tableX;
|
||||
drawInventoryTable(doc, signs, tableX, contentTop, tableW, contentH);
|
||||
} else {
|
||||
drawInventoryTable(doc, signs, MARGIN, contentTop, W - MARGIN * 2, contentH);
|
||||
}
|
||||
|
||||
// ─── SIGN PAGES ───────────────────────────────────────────────────────────────
|
||||
for (let i = 0; i < signs.length; i++) {
|
||||
const sign = signs[i];
|
||||
const photoDataUrl = signPhotoDataUrls[i];
|
||||
pageNum++;
|
||||
doc.addPage();
|
||||
const signLabel = `Sign ${sign.signNumber || (i + 1)} — ${sign.type || 'Sign'}`;
|
||||
addHeader(doc, logo, logoW, logoH, signLabel, pageNum, totalPages);
|
||||
addFooter(doc, clientName, displayDate);
|
||||
|
||||
const top = HEADER_H + 16;
|
||||
const bottom = H - 30;
|
||||
const availH = bottom - top;
|
||||
const photoW = (W - MARGIN * 2 - 20) * 0.45;
|
||||
const specsX = MARGIN + photoW + 20;
|
||||
const specsW = W - MARGIN - specsX;
|
||||
|
||||
if (photoDataUrl) {
|
||||
const photoImg = await loadImage(photoDataUrl);
|
||||
if (photoImg) {
|
||||
const imgAspect = photoImg.naturalWidth / photoImg.naturalHeight;
|
||||
const boxAspect = photoW / availH;
|
||||
let dw, dh, dx, dy;
|
||||
if (imgAspect > boxAspect) {
|
||||
dw = photoW; dh = photoW / imgAspect;
|
||||
dx = MARGIN; dy = top + (availH - dh) / 2;
|
||||
} else {
|
||||
dh = availH; dw = availH * imgAspect;
|
||||
dx = MARGIN + (photoW - dw) / 2; dy = top;
|
||||
}
|
||||
doc.setFillColor(30, 30, 30);
|
||||
doc.rect(MARGIN, top, photoW, availH, 'F');
|
||||
doc.addImage(photoDataUrl, dx, dy, dw, dh);
|
||||
doc.setDrawColor(60, 60, 60);
|
||||
doc.setLineWidth(0.5);
|
||||
doc.rect(MARGIN, top, photoW, availH);
|
||||
}
|
||||
} else {
|
||||
doc.setFillColor(30, 30, 30);
|
||||
doc.rect(MARGIN, top, photoW, availH, 'F');
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(80, 80, 80);
|
||||
doc.text('No Photo', MARGIN + photoW / 2, top + availH / 2, { align: 'center' });
|
||||
doc.setDrawColor(60, 60, 60);
|
||||
doc.setLineWidth(0.5);
|
||||
doc.rect(MARGIN, top, photoW, availH);
|
||||
}
|
||||
|
||||
let sy = top;
|
||||
|
||||
doc.setFillColor(...ACCENT);
|
||||
doc.roundedRect(specsX, sy, 44, 18, 3, 3, 'F');
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(...DARK);
|
||||
doc.text(`#${sign.signNumber || (i + 1)}`, specsX + 22, sy + 12, { align: 'center' });
|
||||
sy += 26;
|
||||
|
||||
doc.setFontSize(16);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(30, 30, 30);
|
||||
doc.text(sign.type || 'Sign Type', specsX, sy);
|
||||
sy += 20;
|
||||
|
||||
doc.setDrawColor(...ACCENT);
|
||||
doc.setLineWidth(1);
|
||||
doc.line(specsX, sy, specsX + specsW, sy);
|
||||
sy += 12;
|
||||
|
||||
const specs = [
|
||||
['Location', sign.location || '—'],
|
||||
['Dimensions', sign.width && sign.height ? `${sign.width}" W × ${sign.height}" H` : '—'],
|
||||
['Material', sign.material || '— (placeholder)'],
|
||||
['Illumination', sign.illumination || '— (placeholder)'],
|
||||
['Condition', sign.condition || '— (placeholder)'],
|
||||
['Mount Type', sign.mountType || '— (placeholder)'],
|
||||
];
|
||||
|
||||
specs.forEach(([label, value]) => {
|
||||
doc.setFontSize(7);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(150, 150, 150);
|
||||
doc.text(label.toUpperCase(), specsX, sy);
|
||||
sy += 9;
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(40, 40, 40);
|
||||
const wrapped = doc.splitTextToSize(value, specsW);
|
||||
doc.text(wrapped, specsX, sy);
|
||||
sy += wrapped.length * 6 + 10;
|
||||
});
|
||||
|
||||
if (sign.notes) {
|
||||
doc.setFontSize(7);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(150, 150, 150);
|
||||
doc.text('NOTES', specsX, sy);
|
||||
sy += 9;
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(80, 80, 80);
|
||||
const noteLines = doc.splitTextToSize(sign.notes, specsW);
|
||||
doc.text(noteLines, specsX, sy);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── SURVEY PHOTOS PAGE ───────────────────────────────────────────────────────
|
||||
const validSurveyPhotos = surveyPhotoDataUrls.filter(Boolean);
|
||||
if (validSurveyPhotos.length > 0) {
|
||||
pageNum++;
|
||||
doc.addPage();
|
||||
addHeader(doc, logo, logoW, logoH, 'Site Photos', pageNum, totalPages);
|
||||
addFooter(doc, clientName, displayDate);
|
||||
|
||||
const top = HEADER_H + 14;
|
||||
const bottom = H - 30;
|
||||
const availW = W - MARGIN * 2;
|
||||
const cols = 4;
|
||||
const rows = 3;
|
||||
const gapX = 10;
|
||||
const gapY = 10;
|
||||
const thumbW = (availW - gapX * (cols - 1)) / cols;
|
||||
const thumbH = (bottom - top - gapY * (rows - 1)) / rows;
|
||||
|
||||
for (let i = 0; i < Math.min(validSurveyPhotos.length, cols * rows); i++) {
|
||||
const dataUrl = validSurveyPhotos[i];
|
||||
const col = i % cols;
|
||||
const row = Math.floor(i / cols);
|
||||
const tx = MARGIN + col * (thumbW + gapX);
|
||||
const ty = top + row * (thumbH + gapY);
|
||||
|
||||
doc.setFillColor(30, 30, 30);
|
||||
doc.rect(tx, ty, thumbW, thumbH, 'F');
|
||||
|
||||
const img = await loadImage(dataUrl);
|
||||
if (img) {
|
||||
const aspect = img.naturalWidth / img.naturalHeight;
|
||||
const boxAspect = thumbW / thumbH;
|
||||
let dw, dh, dx, dy;
|
||||
if (aspect > boxAspect) {
|
||||
dw = thumbW; dh = thumbW / aspect; dx = tx; dy = ty + (thumbH - dh) / 2;
|
||||
} else {
|
||||
dh = thumbH; dw = thumbH * aspect; dx = tx + (thumbW - dw) / 2; dy = ty;
|
||||
}
|
||||
doc.addImage(dataUrl, dx, dy, dw, dh);
|
||||
}
|
||||
doc.setDrawColor(60, 60, 60);
|
||||
doc.setLineWidth(0.3);
|
||||
doc.rect(tx, ty, thumbW, thumbH);
|
||||
}
|
||||
|
||||
if (validSurveyPhotos.length > cols * rows) {
|
||||
doc.setFontSize(8);
|
||||
doc.setFont('helvetica', 'italic');
|
||||
doc.setTextColor(120, 120, 120);
|
||||
doc.text(`+${validSurveyPhotos.length - cols * rows} more photos not shown`, W - MARGIN, bottom + 8, { align: 'right' });
|
||||
}
|
||||
}
|
||||
|
||||
const safePart = (str) => (str || '').replace(/[^a-zA-Z0-9 ]/g, '').trim().replace(/\s+/g, ' ');
|
||||
const filename = [safePart(projectName), safePart(siteAddress), `R${rev}`].filter(Boolean).join('.');
|
||||
doc.save(`${filename || 'BrandBook'}.pdf`);
|
||||
}
|
||||
|
||||
function drawInventoryTable(doc, signs, x, y, w, h) {
|
||||
const colDefs = [
|
||||
{ label: '#', flex: 0.5 },
|
||||
{ label: 'Type', flex: 1.5 },
|
||||
{ label: 'Location', flex: 2 },
|
||||
{ label: 'Dimensions', flex: 1.2 },
|
||||
{ label: 'Notes', flex: 2 },
|
||||
];
|
||||
const totalFlex = colDefs.reduce((s, c) => s + c.flex, 0);
|
||||
const cols = colDefs.map(c => ({ ...c, w: (c.flex / totalFlex) * w }));
|
||||
|
||||
const rowH = 18;
|
||||
doc.setFillColor(30, 30, 30);
|
||||
doc.rect(x, y, w, rowH, 'F');
|
||||
doc.setFontSize(7);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(...ACCENT);
|
||||
|
||||
let cx = x;
|
||||
cols.forEach(col => { doc.text(col.label, cx + 5, y + 12); cx += col.w; });
|
||||
|
||||
let ry = y + rowH;
|
||||
signs.forEach((sign, i) => {
|
||||
if (ry > y + h - rowH) return;
|
||||
const rowData = [
|
||||
sign.signNumber || String(i + 1),
|
||||
sign.type || '—',
|
||||
sign.location || '—',
|
||||
sign.width && sign.height ? `${sign.width}" × ${sign.height}"` : '—',
|
||||
sign.notes || '',
|
||||
];
|
||||
|
||||
doc.setFillColor(i % 2 === 0 ? 248 : 242, i % 2 === 0 ? 248 : 242, i % 2 === 0 ? 248 : 242);
|
||||
doc.rect(x, ry, w, rowH, 'F');
|
||||
doc.setFontSize(8);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(40, 40, 40);
|
||||
|
||||
cx = x;
|
||||
cols.forEach((col, ci) => {
|
||||
const truncated = doc.splitTextToSize(rowData[ci] || '', col.w - 8)[0] || '';
|
||||
doc.text(truncated, cx + 5, ry + 12);
|
||||
cx += col.w;
|
||||
});
|
||||
|
||||
doc.setDrawColor(220, 220, 220);
|
||||
doc.setLineWidth(0.2);
|
||||
doc.line(x, ry + rowH, x + w, ry + rowH);
|
||||
ry += rowH;
|
||||
});
|
||||
|
||||
doc.setDrawColor(180, 180, 180);
|
||||
doc.setLineWidth(0.5);
|
||||
doc.rect(x, y, w, ry - y);
|
||||
|
||||
if (signs.length === 0) {
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'italic');
|
||||
doc.setTextColor(160, 160, 160);
|
||||
doc.text('No signs added yet', x + w / 2, y + rowH + 16, { align: 'center' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import jsPDF from 'jspdf';
|
||||
|
||||
const W = 792;
|
||||
const H = 612;
|
||||
const MARGIN = 36;
|
||||
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}T12:00:00`);
|
||||
return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
async function blobToDataUrl(blob) {
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => resolve(event.target?.result || null);
|
||||
reader.onerror = () => resolve(null);
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
async function resolvePhoto(source) {
|
||||
if (!source) return null;
|
||||
if (source instanceof File || source instanceof Blob) {
|
||||
return blobToDataUrl(source);
|
||||
}
|
||||
if (typeof source === 'string') return source.startsWith('data:') ? source : null;
|
||||
return null;
|
||||
}
|
||||
|
||||
function getFormat(dataUrl) {
|
||||
return dataUrl?.startsWith('data:image/png') ? 'PNG' : 'JPEG';
|
||||
}
|
||||
|
||||
function loadImage(src) {
|
||||
return new Promise((resolve) => {
|
||||
if (!src) return resolve(null);
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => resolve(null);
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
const PX_PER_PT = 300 / 72;
|
||||
|
||||
function toPrintJpeg(dataUrl, img, displayPtW, displayPtH, quality = 0.82) {
|
||||
if (!dataUrl || dataUrl.startsWith('data:image/png')) return dataUrl;
|
||||
const tW = Math.round(displayPtW * PX_PER_PT);
|
||||
const tH = Math.round(displayPtH * PX_PER_PT);
|
||||
if (img.naturalWidth <= tW && img.naturalHeight <= tH) return dataUrl;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = tW;
|
||||
canvas.height = tH;
|
||||
canvas.getContext('2d').drawImage(img, 0, 0, tW, tH);
|
||||
return canvas.toDataURL('image/jpeg', quality);
|
||||
}
|
||||
|
||||
function addHeader(doc, title, pageNum, totalPages) {
|
||||
doc.setFillColor(255, 255, 255);
|
||||
doc.rect(0, 0, W, HEADER_H, 'F');
|
||||
doc.setFillColor(...ACCENT);
|
||||
doc.rect(0, 0, 4, HEADER_H, 'F');
|
||||
doc.setDrawColor(220, 220, 220);
|
||||
doc.setLineWidth(0.3);
|
||||
doc.line(0, HEADER_H, W, HEADER_H);
|
||||
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(...DARK);
|
||||
doc.text(title.toUpperCase(), MARGIN, HEADER_H / 2 + 4);
|
||||
|
||||
doc.setFontSize(8);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(140, 140, 140);
|
||||
doc.text(`${pageNum} / ${totalPages}`, W - MARGIN, HEADER_H / 2 + 4, { align: 'right' });
|
||||
}
|
||||
|
||||
function drawCoverImage(doc, dataUrl, img, x, y, w, h) {
|
||||
if (!dataUrl || !img) return;
|
||||
const srcAspect = img.naturalWidth / img.naturalHeight;
|
||||
const boxAspect = w / h;
|
||||
let drawW;
|
||||
let drawH;
|
||||
let drawX;
|
||||
let drawY;
|
||||
|
||||
if (srcAspect > boxAspect) {
|
||||
drawW = w;
|
||||
drawH = w / srcAspect;
|
||||
drawX = x;
|
||||
drawY = y + (h - drawH) / 2;
|
||||
} else {
|
||||
drawH = h;
|
||||
drawW = h * srcAspect;
|
||||
drawX = x + (w - drawW) / 2;
|
||||
drawY = y;
|
||||
}
|
||||
|
||||
const printableDataUrl = toPrintJpeg(dataUrl, img, drawW, drawH);
|
||||
doc.addImage(printableDataUrl, getFormat(printableDataUrl), drawX, drawY, drawW, drawH);
|
||||
}
|
||||
|
||||
export async function generateSurveyMakerPdf(data) {
|
||||
const { clientName, siteAddress, surveyDate, preparedBy, projectName, signs } = data;
|
||||
const doc = new jsPDF({ orientation: 'landscape', format: 'letter', unit: 'pt', compress: true });
|
||||
|
||||
const signPhotos = await Promise.all((signs || []).map(async (sign) => {
|
||||
const mainDataUrl = await resolvePhoto(sign.mainPhoto);
|
||||
const contextDataUrls = await Promise.all([
|
||||
resolvePhoto(sign.contextPhoto1),
|
||||
resolvePhoto(sign.contextPhoto2),
|
||||
resolvePhoto(sign.contextPhoto3),
|
||||
]);
|
||||
const mainImage = mainDataUrl ? await loadImage(mainDataUrl) : null;
|
||||
const contextImages = await Promise.all(contextDataUrls.map(async (dataUrl) => (dataUrl ? loadImage(dataUrl) : null)));
|
||||
|
||||
return { mainDataUrl, mainImage, contextDataUrls, contextImages };
|
||||
}));
|
||||
|
||||
const totalPages = Math.max(1, 1 + signs.length);
|
||||
|
||||
doc.setFillColor(255, 255, 255);
|
||||
doc.rect(0, 0, W, H, 'F');
|
||||
doc.setFillColor(...ACCENT);
|
||||
doc.rect(0, 0, W, 5, 'F');
|
||||
doc.rect(0, H - 5, W, 5, 'F');
|
||||
|
||||
doc.setFontSize(30);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(...DARK);
|
||||
doc.text(projectName || 'Sign Survey', MARGIN, 88);
|
||||
|
||||
const details = [
|
||||
['Client', clientName || '-'],
|
||||
['Site Address', siteAddress || '-'],
|
||||
['Survey Date', formatDate(surveyDate)],
|
||||
['Prepared By', preparedBy || '-'],
|
||||
['Sign Count', String(signs.length)],
|
||||
];
|
||||
|
||||
let y = 148;
|
||||
details.forEach(([label, value]) => {
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(150, 150, 150);
|
||||
doc.text(label.toUpperCase(), MARGIN, y);
|
||||
y += 16;
|
||||
doc.setFontSize(14);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(40, 40, 40);
|
||||
const lines = doc.splitTextToSize(value, 320);
|
||||
doc.text(lines, MARGIN, y);
|
||||
y += lines.length * 16 + 18;
|
||||
});
|
||||
|
||||
if (signPhotos[0]?.mainDataUrl && signPhotos[0]?.mainImage) {
|
||||
const photoX = 410;
|
||||
const photoY = 110;
|
||||
const photoW = W - photoX - MARGIN;
|
||||
const photoH = 360;
|
||||
drawCoverImage(doc, signPhotos[0].mainDataUrl, signPhotos[0].mainImage, photoX, photoY, photoW, photoH);
|
||||
}
|
||||
|
||||
doc.setFontSize(8);
|
||||
doc.setTextColor(140, 140, 140);
|
||||
doc.text('1 / ' + totalPages, W - MARGIN, H - 18, { align: 'right' });
|
||||
|
||||
for (let index = 0; index < signs.length; index += 1) {
|
||||
const sign = signs[index];
|
||||
const photo = signPhotos[index];
|
||||
doc.addPage();
|
||||
addHeader(doc, sign.signName || `Sign ${index + 1}`, index + 2, totalPages);
|
||||
|
||||
const top = HEADER_H + 20;
|
||||
const leftW = 270;
|
||||
const rightX = MARGIN + leftW + 20;
|
||||
const rightW = W - rightX - MARGIN;
|
||||
const mainPhotoH = 280;
|
||||
const contextGap = 10;
|
||||
const contextPhotoY = top + mainPhotoH + 12;
|
||||
const contextPhotoW = (rightW - contextGap * 2) / 3;
|
||||
const contextPhotoH = H - contextPhotoY - 36;
|
||||
|
||||
doc.setFontSize(18);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(...DARK);
|
||||
doc.text(sign.signName || `Sign ${index + 1}`, MARGIN, top + 10);
|
||||
|
||||
let textY = top + 42;
|
||||
const blocks = [
|
||||
['Measurements', sign.measurements || '-'],
|
||||
['Notes', sign.notes || '-'],
|
||||
];
|
||||
|
||||
blocks.forEach(([label, value]) => {
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(150, 150, 150);
|
||||
doc.text(label.toUpperCase(), MARGIN, textY);
|
||||
textY += 16;
|
||||
doc.setFontSize(11);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(50, 50, 50);
|
||||
const lines = doc.splitTextToSize(value, leftW);
|
||||
doc.text(lines, MARGIN, textY);
|
||||
textY += lines.length * 14 + 24;
|
||||
});
|
||||
|
||||
if (photo?.mainDataUrl && photo?.mainImage) {
|
||||
drawCoverImage(doc, photo.mainDataUrl, photo.mainImage, rightX, top, rightW, mainPhotoH);
|
||||
} else {
|
||||
doc.setFontSize(10);
|
||||
doc.setTextColor(170, 170, 170);
|
||||
doc.text('No Main Photo', rightX + rightW / 2, top + mainPhotoH / 2, { align: 'center' });
|
||||
}
|
||||
|
||||
for (let contextIndex = 0; contextIndex < 3; contextIndex += 1) {
|
||||
const x = rightX + contextIndex * (contextPhotoW + contextGap);
|
||||
|
||||
const contextDataUrl = photo?.contextDataUrls?.[contextIndex];
|
||||
const contextImage = photo?.contextImages?.[contextIndex];
|
||||
if (contextDataUrl && contextImage) {
|
||||
drawCoverImage(doc, contextDataUrl, contextImage, x, contextPhotoY, contextPhotoW, contextPhotoH);
|
||||
} else {
|
||||
doc.setFontSize(9);
|
||||
doc.setTextColor(180, 180, 180);
|
||||
doc.text(`Context ${contextIndex + 1}`, x + contextPhotoW / 2, contextPhotoY + contextPhotoH / 2, { align: 'center' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const safe = (value) => (value || '').replace(/[^a-zA-Z0-9 ]/g, '').trim().replace(/\s+/g, ' ');
|
||||
const filename = [safe(projectName || 'Survey'), safe(clientName), formatDate(surveyDate).replace(/[^a-zA-Z0-9]/g, '')].filter(Boolean).join('.');
|
||||
doc.save(`${filename || 'SurveyMaker'}.pdf`);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export function getCurrentVersionForTask(task, submissions) {
|
||||
return Math.max(
|
||||
task?.current_version || 0,
|
||||
...((submissions || []).map(submission => submission.version_number || 0))
|
||||
);
|
||||
}
|
||||
|
||||
export function getDeadlineSourceSubmission(task, submissions) {
|
||||
const taskSubs = (submissions || []).filter(submission => submission?.task_id === task?.id);
|
||||
if (taskSubs.length === 0) return null;
|
||||
|
||||
const deadlineRow = [...taskSubs]
|
||||
.filter(submission => submission.deadline)
|
||||
.sort((a, b) => {
|
||||
if ((b.version_number ?? 0) !== (a.version_number ?? 0)) return (b.version_number ?? 0) - (a.version_number ?? 0);
|
||||
return new Date(b.submitted_at || 0).getTime() - new Date(a.submitted_at || 0).getTime();
|
||||
})[0];
|
||||
|
||||
if (deadlineRow) return deadlineRow;
|
||||
|
||||
const currentVersion = getCurrentVersionForTask(task, taskSubs);
|
||||
const currentVersionSubs = taskSubs.filter(submission => (submission.version_number || 0) === currentVersion);
|
||||
|
||||
return [...currentVersionSubs].sort((a, b) => {
|
||||
if ((b.version_number ?? 0) !== (a.version_number ?? 0)) return (b.version_number ?? 0) - (a.version_number ?? 0);
|
||||
return new Date(b.submitted_at || 0).getTime() - new Date(a.submitted_at || 0).getTime();
|
||||
})[0] || null;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export async function withTimeout(promise, ms = 12000, label = 'Request') {
|
||||
let timerId;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) => {
|
||||
timerId = window.setTimeout(() => reject(new Error(`${label} timed out`)), ms);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timerId) window.clearTimeout(timerId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user