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:
Krao Hasanee
2026-05-13 14:20:38 -04:00
parent c9e7816e28
commit eee0885811
117 changed files with 17592 additions and 4057 deletions
+54 -1
View File
@@ -2,7 +2,60 @@
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(\"/Users/kraohasanee/Documents/40-49 Fourge:*)", "Bash(\"/Users/kraohasanee/Documents/40-49 Fourge:*)",
"Bash(vercel --version)" "Bash(vercel --version)",
"Bash(vercel --prod)",
"Bash(supabase --version)",
"Bash(brew install:*)",
"Read(//usr/local/bin/**)",
"Read(//Users/kraohasanee/.local/bin/**)",
"Read(//usr/**)",
"Bash(command -v supabase)",
"Read(//Users/kraohasanee/Library/**)",
"Bash(npx supabase:*)",
"Bash(echo $PATH)",
"Bash(supabase functions:*)",
"Bash(npm install:*)",
"Bash(export PATH=\"/opt/homebrew/bin:$PATH\")",
"Read(//opt/homebrew/bin/**)",
"Read(//Users/kraohasanee/.npm/bin/**)",
"Bash(export PATH=\"/usr/local/bin:/opt/homebrew/bin:$PATH\")",
"Read(//Users/kraohasanee/.supabase/**)",
"Bash(supabase status:*)",
"Bash(supabase orgs:*)",
"Bash(supabase projects:*)",
"Bash(supabase db:*)",
"Bash(npm run:*)",
"Bash(npx vercel:*)",
"Bash(curl -vI https://portal.fourgebranding.com)",
"Bash(dig portal.fourgebranding.com A +short)",
"Bash(dig portal.fourgebranding.com CNAME +short)",
"Bash(curl:*)",
"Bash(supabase migration:*)",
"Bash(ls \"/Users/kraohasanee/Documents/40-49 Fourge Branding/41 Website/fourge-portal\"/.env*)",
"Bash(supabase secrets:*)",
"Bash(stripe version:*)",
"Bash(stripe config:*)",
"Bash(stripe checkout:*)",
"Bash(stripe payment_intents list --limit 10)",
"Bash(stripe charges:*)",
"Bash(vercel ls:*)",
"Bash(vercel promote:*)",
"Bash(vercel inspect:*)",
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(d.get\\('gitSource', d.get\\('meta', ''\\)\\)\\)\")",
"Bash(npx vite:*)",
"Bash(wait)",
"Bash(stripe webhook_endpoints list)",
"Bash(grep VITE_SUPABASE_ANON_KEY .env.local)",
"Bash(grep VITE_SUPABASE_ANON_KEY .env.production)",
"Bash(vercel whoami *)",
"Bash(vercel deploy *)",
"Bash(vercel build *)",
"Bash(vercel pull *)",
"Bash(sudo npm *)",
"Bash(npx vercel@latest --prod)",
"Bash(git add *)",
"Bash(git commit -m ' *)",
"Bash(git push *)"
] ]
} }
} }
+1
View File
@@ -23,3 +23,4 @@ dist-ssr
*.sln *.sln
*.sw? *.sw?
.vercel .vercel
supabase/.temp
+98
View File
@@ -0,0 +1,98 @@
import crypto from 'crypto';
import { createClient } from '@supabase/supabase-js';
function json(res, status, body) {
res.status(status).setHeader('Content-Type', 'application/json');
res.setHeader('Cache-Control', 'no-store');
res.send(JSON.stringify(body));
}
async function requireTeam(authHeader) {
const supabaseUrl = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL;
const supabaseAnonKey = process.env.VITE_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error('Supabase auth env is not configured on Vercel.');
}
const callerClient = createClient(supabaseUrl, supabaseAnonKey, {
auth: { persistSession: false, autoRefreshToken: false },
global: { headers: { Authorization: authHeader } },
});
const { data: userData, error: userError } = await callerClient.auth.getUser();
if (userError || !userData?.user) {
return { ok: false, status: 401, message: 'Unauthorized' };
}
const { data: profile, error: profileError } = await callerClient
.from('profiles')
.select('role')
.eq('id', userData.user.id)
.single();
if (profileError) {
return { ok: false, status: 500, message: profileError.message };
}
if (profile?.role !== 'team') {
return { ok: false, status: 403, message: 'Forbidden' };
}
return { ok: true };
}
function getKey() {
const secret = process.env.PASSWORD_VAULT_KEY || '';
if (!secret) throw new Error('PASSWORD_VAULT_KEY is not configured on Vercel.');
return Buffer.from(secret, 'base64');
}
export default async function handler(req, res) {
if (req.method !== 'POST') return json(res, 405, { error: 'Method not allowed' });
try {
const authHeader = req.headers.authorization || '';
if (!authHeader) return json(res, 401, { error: 'No authorization header' });
const auth = await requireTeam(authHeader);
if (!auth.ok) return json(res, auth.status, { error: auth.message });
const { action, plaintext, ciphertext, iv } = req.body || {};
const key = getKey();
if (action === 'encrypt') {
if (!plaintext) return json(res, 400, { error: 'plaintext required' });
const ivBuffer = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', key, ivBuffer);
const encrypted = Buffer.concat([cipher.update(String(plaintext), 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
const packed = Buffer.concat([encrypted, tag]);
return json(res, 200, {
ciphertext: packed.toString('base64'),
iv: ivBuffer.toString('base64'),
});
}
if (action === 'decrypt') {
if (!ciphertext || !iv) return json(res, 400, { error: 'ciphertext and iv required' });
const raw = Buffer.from(String(ciphertext), 'base64');
const ivBuffer = Buffer.from(String(iv), 'base64');
const encrypted = raw.subarray(0, raw.length - 16);
const tag = raw.subarray(raw.length - 16);
const decipher = crypto.createDecipheriv('aes-256-gcm', key, ivBuffer);
decipher.setAuthTag(tag);
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
return json(res, 200, { plaintext: decrypted.toString('utf8') });
}
return json(res, 400, { error: 'Invalid action' });
} catch (error) {
return json(res, 500, { error: error.message || 'Unexpected server error' });
}
}
+496
View File
@@ -0,0 +1,496 @@
import { createClient } from '@supabase/supabase-js';
const DIRECTORY_USAGE_CACHE_TTL_MS = 2 * 60 * 1000;
const directoryUsageCache = new Map();
function json(res, status, body) {
res.status(status).setHeader('Content-Type', 'application/json');
res.setHeader('Cache-Control', 'no-store');
res.send(JSON.stringify(body));
}
function normalizeBaseUrl(url) {
return String(url || '').trim().replace(/\/+$/, '');
}
function normalizePath(path) {
const raw = String(path || '/').trim();
const parts = raw.split('/').filter(Boolean);
const clean = [];
for (const part of parts) {
if (part === '.' || part === '') continue;
if (part === '..') throw new Error('Invalid path');
clean.push(part);
}
return `/${clean.join('/')}`;
}
function joinPath(...parts) {
return normalizePath(parts.join('/'));
}
function safeName(value, fallback) {
const cleaned = String(value || '')
.trim()
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
.replace(/\s+/g, ' ')
.replace(/^-+|-+$/g, '');
return cleaned || fallback;
}
function fillTemplate(template, profile) {
const name = safeName(profile.name, profile.id);
const companyName = safeName(profile.company?.name, name);
const email = safeName(profile.email, profile.id);
const emailName = safeName(String(profile.email || '').split('@')[0], profile.id);
return String(template || '')
.replaceAll('{id}', profile.id)
.replaceAll('{name}', name)
.replaceAll('{companyName}', companyName)
.replaceAll('{email}', email)
.replaceAll('{emailName}', emailName);
}
function getConfig() {
const serverUrl = normalizeBaseUrl(process.env.SEAFILE_SERVER_URL);
const apiToken = process.env.SEAFILE_API_TOKEN;
const repoId = process.env.SEAFILE_REPO_ID;
return {
serverUrl,
webUrl: normalizeBaseUrl(process.env.SEAFILE_WEB_URL || serverUrl),
apiToken,
repoId,
teamRoot: normalizePath(process.env.SEAFILE_TEAM_ROOT_PATH || '/'),
externalRoot: normalizePath(process.env.SEAFILE_EXTERNAL_ROOT_PATH || '/Subcontractors'),
externalTemplate: process.env.SEAFILE_EXTERNAL_FOLDER_TEMPLATE || '{name}',
clientRoot: normalizePath(process.env.SEAFILE_CLIENT_ROOT_PATH || '/Clients'),
clientTemplate: process.env.SEAFILE_CLIENT_FOLDER_TEMPLATE || '{companyName}',
configured: Boolean(serverUrl && apiToken && repoId),
};
}
async function createCallerClient(authHeader) {
const supabaseUrl = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL;
const supabaseAnonKey = process.env.VITE_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error('Supabase auth env is not configured on Vercel.');
}
return createClient(supabaseUrl, supabaseAnonKey, {
auth: { persistSession: false, autoRefreshToken: false },
global: { headers: { Authorization: authHeader } },
});
}
async function requirePortalUser(authHeader) {
const callerClient = await createCallerClient(authHeader);
const { data: userData, error: userError } = await callerClient.auth.getUser();
if (userError || !userData?.user) {
return { ok: false, status: 401, message: 'Unauthorized' };
}
const { data: profile, error: profileError } = await callerClient
.from('profiles')
.select('id, name, role, company:companies(id, name)')
.eq('id', userData.user.id)
.single();
if (profileError) {
return { ok: false, status: 500, message: profileError.message };
}
if (!['team', 'external', 'client'].includes(profile?.role)) {
return { ok: false, status: 403, message: 'Forbidden' };
}
return {
ok: true,
callerClient,
profile: {
...profile,
email: userData.user.email,
},
};
}
function getUserRoot(config, profile) {
if (profile.role === 'team') return config.teamRoot;
if (profile.role === 'client') {
const templated = fillTemplate(config.clientTemplate, profile);
if (templated.startsWith('/')) return normalizePath(templated);
return joinPath(config.clientRoot, templated);
}
const templated = fillTemplate(config.externalTemplate, profile);
if (templated.startsWith('/')) return normalizePath(templated);
return joinPath(config.externalRoot, templated);
}
function resolveSeafilePath(config, profile, requestedPath = '/') {
const root = getUserRoot(config, profile);
const virtualPath = normalizePath(requestedPath);
return {
root,
virtualPath,
seafilePath: joinPath(root, virtualPath),
};
}
async function seafileRequest(config, endpoint, options = {}) {
const response = await fetch(`${config.serverUrl}${endpoint}`, {
...options,
headers: {
Authorization: `Token ${config.apiToken}`,
Accept: 'application/json',
...(options.headers || {}),
},
});
const text = await response.text();
let body = text;
try {
body = text ? JSON.parse(text) : null;
} catch {
body = text;
}
if (!response.ok) {
const message = typeof body === 'object' && body?.error_msg ? body.error_msg : text || `Seafile returned ${response.status}`;
const error = new Error(message);
error.status = response.status;
throw error;
}
return body;
}
async function createSeafileFolder(config, path) {
const body = new URLSearchParams({ operation: 'mkdir', create_parents: 'true' });
await seafileRequest(config, `/api2/repos/${encodeURIComponent(config.repoId)}/dir/?p=${encodeURIComponent(path)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
}
async function createSeafileFolderIfMissing(config, path) {
try {
await createSeafileFolder(config, path);
return true;
} catch (error) {
const message = String(error.message || '').toLowerCase();
if (error.status === 400 || message.includes('already') || message.includes('exist')) return false;
throw error;
}
}
function getCachedDirectoryUsage(cacheKey) {
const cached = directoryUsageCache.get(cacheKey);
if (!cached) return null;
if ((Date.now() - cached.timestamp) > DIRECTORY_USAGE_CACHE_TTL_MS) {
directoryUsageCache.delete(cacheKey);
return null;
}
return cached.bytes;
}
function setCachedDirectoryUsage(cacheKey, bytes) {
directoryUsageCache.set(cacheKey, {
bytes: Number(bytes) || 0,
timestamp: Date.now(),
});
}
async function listDirectoryEntries(config, path) {
const entries = await seafileRequest(
config,
`/api2/repos/${encodeURIComponent(config.repoId)}/dir/?p=${encodeURIComponent(path)}`
);
return Array.isArray(entries) ? entries : [];
}
async function getDirectoryUsageBytes(config, path, prefetchedEntries = null) {
const normalizedPath = normalizePath(path);
const cacheKey = `${config.repoId}:${normalizedPath}`;
const cached = getCachedDirectoryUsage(cacheKey);
if (cached != null) return cached;
const entries = prefetchedEntries || await listDirectoryEntries(config, normalizedPath);
let total = 0;
for (const entry of entries) {
if (entry.type === 'file') {
total += Number(entry.size || 0);
continue;
}
if (entry.type === 'dir') {
total += await getDirectoryUsageBytes(config, joinPath(normalizedPath, entry.name));
}
}
setCachedDirectoryUsage(cacheKey, total);
return total;
}
function clearDirectoryUsageCache(config, path) {
const normalizedPath = normalizePath(path);
const prefixes = [];
let cursor = normalizedPath;
while (true) {
prefixes.push(`${config.repoId}:${cursor}`);
if (cursor === '/') break;
cursor = parentDir(cursor);
}
for (const key of prefixes) {
directoryUsageCache.delete(key);
}
}
function entryPath(parent, name) {
return joinPath(parent, name);
}
function parentDir(path) {
const normalized = normalizePath(path);
const parts = normalized.split('/').filter(Boolean);
parts.pop();
return `/${parts.join('/')}`;
}
function basename(path) {
const parts = normalizePath(path).split('/').filter(Boolean);
return parts[parts.length - 1] || '';
}
async function syncManagedFolders(config, auth) {
if (auth.profile.role !== 'team') {
const error = new Error('Team only');
error.status = 403;
throw error;
}
const [{ data: companies, error: companiesError }, { data: externals, error: externalsError }] = await Promise.all([
auth.callerClient.from('companies').select('id, name').order('name'),
auth.callerClient.from('profiles').select('id, name, email, role').eq('role', 'external').order('name'),
]);
if (companiesError) throw new Error(companiesError.message);
if (externalsError) throw new Error(externalsError.message);
const folderPaths = [
config.clientRoot,
config.externalRoot,
...(companies || []).map(company => {
const profile = { id: company.id, name: company.name, email: '', company };
const templated = fillTemplate(config.clientTemplate, profile);
return templated.startsWith('/') ? normalizePath(templated) : joinPath(config.clientRoot, templated);
}),
...(externals || []).map(profile => {
const templated = fillTemplate(config.externalTemplate, profile);
return templated.startsWith('/') ? normalizePath(templated) : joinPath(config.externalRoot, templated);
}),
];
const uniquePaths = [...new Set(folderPaths)].filter(path => path !== '/');
let created = 0;
for (const path of uniquePaths) {
if (await createSeafileFolderIfMissing(config, path)) created += 1;
}
return {
created,
checked: uniquePaths.length,
clients: companies?.length || 0,
subcontractors: externals?.length || 0,
};
}
export default async function handler(req, res) {
try {
const authHeader = req.headers.authorization || '';
if (!authHeader) return json(res, 401, { error: 'No authorization header' });
const auth = await requirePortalUser(authHeader);
if (!auth.ok) return json(res, auth.status, { error: auth.message });
const config = getConfig();
if (!config.configured) {
return json(res, 200, {
configured: false,
error: 'Seafile is not configured yet.',
requiredEnv: ['SEAFILE_SERVER_URL', 'SEAFILE_API_TOKEN', 'SEAFILE_REPO_ID'],
});
}
const action = req.query.action || (req.method === 'GET' ? 'list' : '');
const requestedPath = req.query.path || req.body?.path;
const resolved = resolveSeafilePath(config, auth.profile, requestedPath || '/');
const invalidateUsage = req.query.invalidateUsage === '1';
if (req.method === 'POST' && action === 'sync-folders') {
const result = await syncManagedFolders(config, auth);
return json(res, 200, { success: true, ...result });
}
if (req.method === 'GET' && action === 'config') {
return json(res, 200, {
configured: true,
role: auth.profile.role,
root: resolved.root,
webUrl: config.webUrl,
});
}
if (req.method === 'GET' && action === 'list') {
if (invalidateUsage) clearDirectoryUsageCache(config, resolved.seafilePath);
let entries;
try {
entries = await listDirectoryEntries(config, resolved.seafilePath);
} catch (error) {
if (!['external', 'client'].includes(auth.profile.role) || resolved.virtualPath !== '/') throw error;
await createSeafileFolder(config, resolved.root);
entries = await listDirectoryEntries(config, resolved.seafilePath);
}
const normalizedEntries = (Array.isArray(entries) ? entries : []).map((item) => {
const itemPath = entryPath(resolved.virtualPath, item.name);
return {
id: item.id,
name: item.name,
type: item.type,
size: item.type === 'file' ? Number(item.size || 0) : 0,
aggregateSize: item.type === 'file' ? Number(item.size || 0) : null,
mtime: item.mtime || null,
permission: item.permission || null,
path: itemPath,
};
}).sort((a, b) => {
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
return a.name.localeCompare(b.name);
});
return json(res, 200, {
configured: true,
path: resolved.virtualPath,
canGoUp: resolved.virtualPath !== '/',
parentPath: parentDir(resolved.virtualPath),
entries: normalizedEntries,
webUrl: config.webUrl,
});
}
if (req.method === 'GET' && action === 'download') {
const url = await seafileRequest(
config,
`/api2/repos/${encodeURIComponent(config.repoId)}/file/?p=${encodeURIComponent(resolved.seafilePath)}&reuse=1`
);
return json(res, 200, { url });
}
if (req.method === 'POST' && action === 'mkdir') {
const folderName = safeName(req.body?.name, '');
if (!folderName) return json(res, 400, { error: 'Folder name is required.' });
const folderPath = joinPath(resolved.seafilePath, folderName);
await createSeafileFolder(config, folderPath);
clearDirectoryUsageCache(config, resolved.seafilePath);
return json(res, 200, { success: true });
}
if (req.method === 'POST' && action === 'upload-link') {
const uploadLink = await seafileRequest(
config,
`/api2/repos/${encodeURIComponent(config.repoId)}/upload-link/?p=${encodeURIComponent(resolved.seafilePath)}`
);
return json(res, 200, {
uploadLink: typeof uploadLink === 'string' ? uploadLink : String(uploadLink || ''),
parentDir: resolved.seafilePath,
});
}
if (req.method === 'POST' && action === 'move') {
const srcPath = req.body?.srcPath;
const dstDir = req.body?.dstDir;
if (!srcPath || !dstDir) return json(res, 400, { error: 'srcPath and dstDir are required.' });
const resolvedSrc = resolveSeafilePath(config, auth.profile, srcPath);
const resolvedDst = resolveSeafilePath(config, auth.profile, dstDir);
const itemName = basename(resolvedSrc.seafilePath);
const srcDir = parentDir(resolvedSrc.seafilePath);
if (!itemName) return json(res, 400, { error: 'Cannot move root.' });
const type = req.body?.type || 'file';
const endpoint = type === 'dir'
? `/api2/repos/${encodeURIComponent(config.repoId)}/dir/?p=${encodeURIComponent(resolvedSrc.seafilePath)}`
: `/api2/repos/${encodeURIComponent(config.repoId)}/file/?p=${encodeURIComponent(resolvedSrc.seafilePath)}`;
const body = new URLSearchParams({
operation: 'move',
dst_repo: config.repoId,
dst_dir: resolvedDst.seafilePath,
});
await seafileRequest(config, endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
clearDirectoryUsageCache(config, srcDir);
clearDirectoryUsageCache(config, resolvedDst.seafilePath);
return json(res, 200, { success: true });
}
if (req.method === 'DELETE' && action === 'delete') {
const type = req.query.type || req.body?.type;
if (!['file', 'dir'].includes(type)) return json(res, 400, { error: 'Valid item type is required.' });
const itemName = basename(resolved.seafilePath);
const itemParent = parentDir(resolved.seafilePath);
if (!itemName) return json(res, 400, { error: 'Cannot delete the root folder.' });
const body = new URLSearchParams({
file_names: itemName,
});
await seafileRequest(
config,
`/api2/repos/${encodeURIComponent(config.repoId)}/fileops/delete/?p=${encodeURIComponent(itemParent)}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
}
);
clearDirectoryUsageCache(config, itemParent);
return json(res, 200, { success: true });
}
return json(res, 405, { error: 'Method not allowed' });
} catch (error) {
return json(res, error.status || 500, { error: error.message || 'Unexpected Seafile error' });
}
}
+446
View File
@@ -0,0 +1,446 @@
import { createClient } from '@supabase/supabase-js';
const STATUS_ENDPOINTS = {
supabase: 'https://supabase.statuspage.io/api/v2/summary.json',
vercel: 'https://www.vercel-status.com/api/v2/summary.json',
};
const SUPABASE_LIMITS = {
storageBytes: 1024 * 1024 * 1024,
databaseBytes: 500 * 1024 * 1024,
egressBytes: 5 * 1024 * 1024 * 1024,
};
const VERCEL_LIMITS = {
fastDataTransferBytes: 100 * 1024 * 1024 * 1024,
edgeRequests: 1_000_000,
functionInvocations: 1_000_000,
activeCpuHours: 4,
};
function json(res, status, body) {
res.status(status).setHeader('Content-Type', 'application/json');
res.setHeader('Cache-Control', 'no-store');
res.send(JSON.stringify(body));
}
function toNumber(value) {
if (value === undefined || value === null || value === '') return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function makeQuota(id, label, used, limit, unit, note, source = 'live') {
return {
id,
label,
used,
limit,
unit,
note,
source,
};
}
async function fetchJson(url) {
const response = await fetch(url, {
headers: { 'User-Agent': 'FourgePortal/1.0' },
});
if (!response.ok) {
throw new Error(`Request failed (${response.status})`);
}
return response.json();
}
async function fetchStatusSummary(url) {
try {
const summary = await fetchJson(url);
return {
ok: true,
status: summary.status || null,
updatedAt: summary.page?.updated_at || null,
components: Array.isArray(summary.components)
? summary.components.map(component => ({
id: component.id,
name: component.name,
status: component.status,
}))
: [],
};
} catch (error) {
return {
ok: false,
status: { indicator: 'major', description: error.message || 'Status unavailable' },
updatedAt: null,
components: [],
};
}
}
async function requireTeam(authHeader) {
const supabaseUrl = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL;
const supabaseAnonKey = process.env.VITE_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error('Supabase auth env is not configured on Vercel.');
}
const callerClient = createClient(supabaseUrl, supabaseAnonKey, {
auth: { persistSession: false, autoRefreshToken: false },
global: { headers: { Authorization: authHeader } },
});
const { data: userData, error: userError } = await callerClient.auth.getUser();
if (userError || !userData?.user) {
return { ok: false, status: 401, message: 'Unauthorized' };
}
const { data: profile, error: profileError } = await callerClient
.from('profiles')
.select('role')
.eq('id', userData.user.id)
.single();
if (profileError) {
return { ok: false, status: 500, message: profileError.message };
}
if (profile?.role !== 'team') {
return { ok: false, status: 403, message: 'Forbidden' };
}
return { ok: true, supabaseUrl };
}
async function countRows(client, table) {
const { count, error } = await client.from(table).select('id', { count: 'exact', head: true });
if (error) throw error;
return count || 0;
}
async function listStorageFolder(storageClient, bucketId, folder = '') {
const pageSize = 100;
let offset = 0;
const entries = [];
while (true) {
const { data, error } = await storageClient.from(bucketId).list(folder, {
limit: pageSize,
offset,
sortBy: { column: 'name', order: 'asc' },
});
if (error) throw error;
if (!data?.length) break;
entries.push(...data);
if (data.length < pageSize) break;
offset += pageSize;
}
return entries;
}
function isStorageFile(entry) {
return Boolean(entry?.metadata && typeof entry.metadata === 'object' && 'size' in entry.metadata);
}
async function getBucketUsage(storageClient, bucketId, folder = '') {
let totalBytes = 0;
let totalFiles = 0;
const entries = await listStorageFolder(storageClient, bucketId, folder);
for (const entry of entries) {
if (isStorageFile(entry)) {
totalFiles += 1;
totalBytes += Number(entry.metadata.size || 0);
continue;
}
const childFolder = folder ? `${folder}/${entry.name}` : entry.name;
const childUsage = await getBucketUsage(storageClient, bucketId, childFolder);
totalFiles += childUsage.totalFiles;
totalBytes += childUsage.totalBytes;
}
return { totalBytes, totalFiles };
}
async function getStorageUsage(admin) {
const { data: buckets, error } = await admin.storage.listBuckets();
if (error) throw error;
const bucketEntries = [];
let totalBytes = 0;
let totalFiles = 0;
for (const bucket of buckets || []) {
const bucketUsage = await getBucketUsage(admin.storage, bucket.id);
totalBytes += bucketUsage.totalBytes;
totalFiles += bucketUsage.totalFiles;
bucketEntries.push({ bucketId: bucket.id, bytes: bucketUsage.totalBytes });
}
bucketEntries.sort((a, b) => b.bytes - a.bytes);
return { totalBytes, totalFiles, buckets: bucketEntries };
}
async function getSupabaseUsageSnapshot(supabaseUrl, overridesResult) {
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
const databaseBytesFallback = toNumber(process.env.SUPABASE_DB_SIZE_BYTES);
const egressBytesFallback = toNumber(process.env.SUPABASE_EGRESS_BYTES);
const overrides = overridesResult?.data || null;
const egressBytes = toNumber(overrides?.supabase_egress_bytes) ?? egressBytesFallback;
if (!serviceRoleKey) {
return {
configured: false,
message: 'Set SUPABASE_SERVICE_ROLE_KEY on Vercel to show live Supabase usage.',
quotas: [
makeQuota('storage', 'Storage', null, SUPABASE_LIMITS.storageBytes, 'bytes', 'Live storage usage is not configured.', 'unavailable'),
makeQuota('database', 'Database size', databaseBytesFallback, SUPABASE_LIMITS.databaseBytes, 'bytes', databaseBytesFallback === null ? 'Run the latest migration or set SUPABASE_DB_SIZE_BYTES manually.' : 'Manual value from env.', databaseBytesFallback === null ? 'unavailable' : 'manual'),
makeQuota('egress', 'Egress', egressBytes, SUPABASE_LIMITS.egressBytes, 'bytes', egressBytes === null ? 'Set this on the Server Status page when you want to track current egress manually.' : 'Manual value saved from Server Status.', egressBytes === null ? 'unavailable' : 'manual'),
],
stats: [],
};
}
const admin = createClient(supabaseUrl, serviceRoleKey, {
auth: { persistSession: false, autoRefreshToken: false },
});
const [
storageUsage,
companyCount,
projectCount,
taskCount,
brandBookCount,
profileCount,
databaseSizeResult,
] = await Promise.all([
getStorageUsage(admin),
countRows(admin, 'companies'),
countRows(admin, 'projects'),
countRows(admin, 'tasks'),
countRows(admin, 'brand_books'),
countRows(admin, 'profiles'),
admin.rpc('get_database_size_bytes'),
]);
const databaseBytes = databaseSizeResult.error
? null
: toNumber(databaseSizeResult.data);
return {
configured: true,
message: null,
quotas: [
makeQuota('storage', 'Storage', storageUsage.totalBytes, SUPABASE_LIMITS.storageBytes, 'bytes', `${storageUsage.totalFiles} file${storageUsage.totalFiles === 1 ? '' : 's'} across ${storageUsage.buckets.length} bucket${storageUsage.buckets.length === 1 ? '' : 's'}.`),
makeQuota(
'database',
'Database size',
databaseBytes,
SUPABASE_LIMITS.databaseBytes,
'bytes',
databaseBytes === null
? 'Run the latest Supabase migration to enable live database-size reporting.'
: 'Live reading from Supabase.',
databaseBytes === null ? 'unavailable' : 'live'
),
makeQuota('egress', 'Egress', egressBytes, SUPABASE_LIMITS.egressBytes, 'bytes', egressBytes === null ? 'Set this on the Server Status page when you want to track current egress manually.' : 'Manual value saved from Server Status.', egressBytes === null ? 'unavailable' : 'manual'),
],
stats: [
{ label: 'Storage Files', value: storageUsage.totalFiles },
{ label: 'Buckets', value: storageUsage.buckets.length },
{ label: 'Companies', value: companyCount },
{ label: 'Projects', value: projectCount },
{ label: 'Jobs', value: taskCount },
{ label: 'Users', value: profileCount },
{ label: 'Brand Books', value: brandBookCount },
],
buckets: storageUsage.buckets.slice(0, 5),
};
}
async function getVercelUsageSnapshot() {
const vercelToken = process.env.VERCEL_TOKEN;
const teamId = process.env.VERCEL_TEAM_ID || process.env.VERCEL_ORG_ID || '';
const projectId = process.env.VERCEL_PROJECT_ID || '';
const fastDataTransferBytes = toNumber(process.env.VERCEL_FAST_DATA_TRANSFER_BYTES);
const edgeRequests = toNumber(process.env.VERCEL_EDGE_REQUESTS);
const functionInvocations = toNumber(process.env.VERCEL_FUNCTION_INVOCATIONS);
const activeCpuHours = toNumber(process.env.VERCEL_ACTIVE_CPU_HOURS);
const quotas = [
makeQuota('fast-data-transfer', 'Fast Data Transfer', fastDataTransferBytes, VERCEL_LIMITS.fastDataTransferBytes, 'bytes', fastDataTransferBytes === null ? 'Optional: set VERCEL_FAST_DATA_TRANSFER_BYTES to visualize current usage.' : 'Manual value from env.', fastDataTransferBytes === null ? 'unavailable' : 'manual'),
makeQuota('edge-requests', 'Edge Requests', edgeRequests, VERCEL_LIMITS.edgeRequests, 'count', edgeRequests === null ? 'Optional: set VERCEL_EDGE_REQUESTS to visualize current usage.' : 'Manual value from env.', edgeRequests === null ? 'unavailable' : 'manual'),
makeQuota('function-invocations', 'Function Invocations', functionInvocations, VERCEL_LIMITS.functionInvocations, 'count', functionInvocations === null ? 'Optional: set VERCEL_FUNCTION_INVOCATIONS to visualize current usage.' : 'Manual value from env.', functionInvocations === null ? 'unavailable' : 'manual'),
makeQuota('active-cpu', 'Functions Active CPU', activeCpuHours, VERCEL_LIMITS.activeCpuHours, 'hours', activeCpuHours === null ? 'Optional: set VERCEL_ACTIVE_CPU_HOURS to visualize current usage.' : 'Manual value from env.', activeCpuHours === null ? 'unavailable' : 'manual'),
];
if (!vercelToken) {
return {
configured: false,
message: 'Set VERCEL_TOKEN on Vercel to show project and deployment counts.',
quotas,
stats: [],
};
}
const authHeaders = { Authorization: `Bearer ${vercelToken}` };
const teamQuery = teamId ? `?teamId=${encodeURIComponent(teamId)}` : '';
const deploymentParams = new URLSearchParams({
limit: '20',
since: String(Date.now() - 30 * 24 * 60 * 60 * 1000),
});
if (projectId) deploymentParams.set('projectId', projectId);
if (teamId) deploymentParams.set('teamId', teamId);
const [projectsResponse, deploymentsResponse] = await Promise.all([
fetch(`https://api.vercel.com/v9/projects${teamQuery}`, { headers: authHeaders }),
fetch(`https://api.vercel.com/v6/deployments?${deploymentParams.toString()}`, { headers: authHeaders }),
]);
let projectCount = null;
let deployments30d = null;
let deploymentsToday = null;
if (projectsResponse.ok) {
const projectsJson = await projectsResponse.json();
projectCount = Array.isArray(projectsJson.projects) ? projectsJson.projects.length : null;
}
if (deploymentsResponse.ok) {
const deploymentsJson = await deploymentsResponse.json();
const deployments = Array.isArray(deploymentsJson.deployments) ? deploymentsJson.deployments : [];
deployments30d = deployments.length;
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
deploymentsToday = deployments.filter(deployment => {
const createdAt = Number(deployment.createdAt || 0);
return createdAt >= todayStart.getTime();
}).length;
}
return {
configured: true,
message: null,
quotas,
stats: [
...(projectCount === null ? [] : [{ label: 'Projects', value: projectCount }]),
...(deployments30d === null ? [] : [{ label: 'Deployments (30d)', value: deployments30d }]),
...(deploymentsToday === null ? [] : [{ label: 'Deployments Today', value: deploymentsToday }]),
],
};
}
function applyManualOverrides(services, overrides) {
if (!overrides) return services;
const updateQuota = (serviceKey, quotaId, value, note) => {
const service = services[serviceKey];
if (!service) return;
service.usage.quotas = (service.usage.quotas || []).map(quota =>
quota.id === quotaId
? {
...quota,
used: toNumber(value),
note: toNumber(value) === null ? quota.note : note,
source: toNumber(value) === null ? quota.source : 'manual',
}
: quota
);
};
updateQuota('supabase', 'egress', overrides.supabase_egress_bytes, 'Manual value saved from Server Status.');
updateQuota('vercel', 'fast-data-transfer', overrides.vercel_fast_data_transfer_bytes, 'Manual value saved from Server Status.');
updateQuota('vercel', 'edge-requests', overrides.vercel_edge_requests, 'Manual value saved from Server Status.');
updateQuota('vercel', 'function-invocations', overrides.vercel_function_invocations, 'Manual value saved from Server Status.');
updateQuota('vercel', 'active-cpu', overrides.vercel_active_cpu_hours, 'Manual value saved from Server Status.');
return services;
}
export default async function handler(req, res) {
if (!['GET', 'POST'].includes(req.method)) {
return json(res, 405, { error: 'Method not allowed' });
}
const authHeader = req.headers.authorization || '';
if (!authHeader) {
return json(res, 401, { error: 'Missing authorization header' });
}
try {
const authResult = await requireTeam(authHeader);
if (!authResult.ok) {
return json(res, authResult.status, { error: authResult.message });
}
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
const admin = serviceRoleKey
? createClient(authResult.supabaseUrl, serviceRoleKey, {
auth: { persistSession: false, autoRefreshToken: false },
})
: null;
if (req.method === 'POST') {
if (!admin) return json(res, 500, { error: 'SUPABASE_SERVICE_ROLE_KEY is required to save overrides.' });
const body = typeof req.body === 'string' ? JSON.parse(req.body || '{}') : (req.body || {});
const payload = {
id: true,
supabase_egress_bytes: toNumber(body.supabase_egress_bytes),
vercel_fast_data_transfer_bytes: toNumber(body.vercel_fast_data_transfer_bytes),
vercel_edge_requests: toNumber(body.vercel_edge_requests),
vercel_function_invocations: toNumber(body.vercel_function_invocations),
vercel_active_cpu_hours: toNumber(body.vercel_active_cpu_hours),
updated_at: new Date().toISOString(),
};
const { error } = await admin.from('server_status_overrides').upsert(payload);
if (error) return json(res, 500, { error: error.message });
return json(res, 200, { success: true });
}
const overridesResult = admin
? await admin.from('server_status_overrides').select('*').eq('id', true).maybeSingle()
: { data: null, error: null };
const [supabaseStatus, vercelStatus, supabaseUsage, vercelUsage] = await Promise.all([
fetchStatusSummary(STATUS_ENDPOINTS.supabase),
fetchStatusSummary(STATUS_ENDPOINTS.vercel),
getSupabaseUsageSnapshot(authResult.supabaseUrl, overridesResult),
getVercelUsageSnapshot(),
]);
const services = applyManualOverrides({
supabase: {
name: 'Supabase',
provider: 'supabase',
status: supabaseStatus,
usage: supabaseUsage,
limits: SUPABASE_LIMITS,
},
vercel: {
name: 'Vercel',
provider: 'vercel',
status: vercelStatus,
usage: vercelUsage,
limits: VERCEL_LIMITS,
},
}, overridesResult.data);
return json(res, 200, {
checkedAt: new Date().toISOString(),
overrides: overridesResult.data || null,
services,
});
} catch (error) {
return json(res, 500, { error: error.message || 'Server status failed' });
}
}
+12 -1
View File
@@ -5,7 +5,18 @@ import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config' import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([ export default defineConfig([
globalIgnores(['dist']), globalIgnores(['dist', '.vercel']),
{
files: ['api/**/*.js'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.node,
sourceType: 'module',
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
{ {
files: ['**/*.{js,jsx}'], files: ['**/*.{js,jsx}'],
extends: [ extends: [
+4
View File
@@ -3,6 +3,10 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="theme-color" content="#111111" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>fourge-portal</title> <title>fourge-portal</title>
</head> </head>
File diff suppressed because it is too large Load Diff
+114
View File
@@ -9,8 +9,11 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@supabase/supabase-js": "^2.99.3", "@supabase/supabase-js": "^2.99.3",
"heic-to": "^1.4.2",
"heic2any": "^0.0.4",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.7", "jspdf-autotable": "^5.0.7",
"jszip": "^3.10.1",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-router-dom": "^7.13.1" "react-router-dom": "^7.13.1"
@@ -1328,6 +1331,12 @@
"url": "https://opencollective.com/core-js" "url": "https://opencollective.com/core-js"
} }
}, },
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -1787,6 +1796,18 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/heic-to": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/heic-to/-/heic-to-1.4.2.tgz",
"integrity": "sha512-y69thwxfNcEm2Vk8lbOD/cMabnvMJyOREfJYiCHcXCDqlfcPyJoBhyRc8+iDe1B95LRfpbTOpzxzY1xbRkdwBA==",
"license": "LGPL-3.0"
},
"node_modules/heic2any": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/heic2any/-/heic2any-0.0.4.tgz",
"integrity": "sha512-3lLnZiDELfabVH87htnRolZ2iehX9zwpRyGNz22GKXIu0fznlblf0/ftppXKNqS26dqFSeqfIBhAmAj/uSp0cA==",
"license": "MIT"
},
"node_modules/hermes-estree": { "node_modules/hermes-estree": {
"version": "0.25.1", "version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
@@ -1837,6 +1858,12 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/import-fresh": { "node_modules/import-fresh": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -1864,6 +1891,12 @@
"node": ">=0.8.19" "node": ">=0.8.19"
} }
}, },
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/iobuffer": { "node_modules/iobuffer": {
"version": "5.4.0", "version": "5.4.0",
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
@@ -1893,6 +1926,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/isexe": { "node_modules/isexe": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -1993,6 +2032,24 @@
"jspdf": "^2 || ^3 || ^4" "jspdf": "^2 || ^3 || ^4"
} }
}, },
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/jszip/node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/keyv": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -2017,6 +2074,15 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.32.0", "version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -2519,6 +2585,12 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -2598,6 +2670,21 @@
"react-dom": ">=18" "react-dom": ">=18"
} }
}, },
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/regenerator-runtime": { "node_modules/regenerator-runtime": {
"version": "0.13.11", "version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
@@ -2666,6 +2753,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.27.0", "version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -2688,6 +2781,12 @@
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -2731,6 +2830,15 @@
"node": ">=0.1.14" "node": ">=0.1.14"
} }
}, },
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/strip-json-comments": { "node_modules/strip-json-comments": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -2860,6 +2968,12 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utrie": { "node_modules/utrie": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
+3
View File
@@ -11,8 +11,11 @@
}, },
"dependencies": { "dependencies": {
"@supabase/supabase-js": "^2.99.3", "@supabase/supabase-js": "^2.99.3",
"heic-to": "^1.4.2",
"heic2any": "^0.0.4",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.7", "jspdf-autotable": "^5.0.7",
"jszip": "^3.10.1",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-router-dom": "^7.13.1" "react-router-dom": "^7.13.1"
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 688 B

Executable → Regular
+4 -1
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 302 B

+113
View File
@@ -0,0 +1,113 @@
/**
* cleanup-orphaned-storage.mjs
*
* Removes files in the `submissions` storage bucket that have no matching
* row in `submission_files`. These are orphaned uploads from before the
* silent-failure bug was fixed.
*
* Usage:
* SUPABASE_SERVICE_ROLE_KEY=<your-key> node scripts/cleanup-orphaned-storage.mjs
*
* The service role key is in Supabase dashboard → Project Settings → API.
* Run once, then you can delete this script if you like.
*/
import { createClient } from '@supabase/supabase-js';
const SUPABASE_URL = 'https://fqflxxqvennhvoeywrdw.supabase.co';
const SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!SERVICE_ROLE_KEY) {
console.error('Error: SUPABASE_SERVICE_ROLE_KEY env var is required.');
console.error(' SUPABASE_SERVICE_ROLE_KEY=<key> node scripts/cleanup-orphaned-storage.mjs');
process.exit(1);
}
const supabase = createClient(SUPABASE_URL, SERVICE_ROLE_KEY);
async function listAllStorageFiles(bucket) {
// Storage structure: submissions/{taskId}/{timestamp}_{filename}
// List the root to get task-ID "folders", then list each folder for files.
const allPaths = [];
const { data: folders, error: folderError } = await supabase.storage
.from(bucket)
.list('', { limit: 1000, sortBy: { column: 'name', order: 'asc' } });
if (folderError) throw new Error(`Failed to list root: ${folderError.message}`);
if (!folders?.length) return allPaths;
for (const folder of folders) {
// Supabase returns folders as items with `id: null`
if (folder.id !== null) {
// It's a top-level file (rare, skip)
allPaths.push(folder.name);
continue;
}
const { data: files, error: fileError } = await supabase.storage
.from(bucket)
.list(folder.name, { limit: 1000, sortBy: { column: 'name', order: 'asc' } });
if (fileError) {
console.warn(` Warning: failed to list ${folder.name}/: ${fileError.message}`);
continue;
}
for (const file of files ?? []) {
if (file.id !== null) {
allPaths.push(`${folder.name}/${file.name}`);
}
}
}
return allPaths;
}
async function main() {
console.log('Fetching all submission_files records from database...');
const { data: dbFiles, error: dbError } = await supabase
.from('submission_files')
.select('storage_path')
.not('storage_path', 'is', null);
if (dbError) throw new Error(`DB query failed: ${dbError.message}`);
const knownPaths = new Set((dbFiles ?? []).map(r => r.storage_path));
console.log(` ${knownPaths.size} file records found in database.`);
console.log('\nListing all files in submissions storage bucket...');
const storagePaths = await listAllStorageFiles('submissions');
console.log(` ${storagePaths.length} files found in storage.`);
const orphans = storagePaths.filter(p => !knownPaths.has(p));
console.log(`\n${orphans.length} orphaned file(s) found (in storage but not in DB).`);
if (orphans.length === 0) {
console.log('Nothing to clean up.');
return;
}
console.log('\nOrphaned files:');
orphans.forEach(p => console.log(` ${p}`));
// Delete in batches of 100 (Supabase limit)
const BATCH = 100;
let deleted = 0;
for (let i = 0; i < orphans.length; i += BATCH) {
const batch = orphans.slice(i, i + BATCH);
const { error: delError } = await supabase.storage.from('submissions').remove(batch);
if (delError) {
console.error(` Error deleting batch: ${delError.message}`);
} else {
deleted += batch.length;
}
}
console.log(`\nDone. ${deleted}/${orphans.length} orphaned file(s) deleted.`);
}
main().catch(err => {
console.error('Fatal error:', err.message);
process.exit(1);
});
+90 -27
View File
@@ -3,15 +3,35 @@ import { NavLink, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
function TeamNav({ onNav }) { function TeamNav({ onNav }) {
const primaryLinks = [
{ to: '/dashboard', label: 'Dashboard' },
{ to: '/requests', label: 'Requests' },
{ to: '/file-sharing', label: 'File Sharing' },
{ to: '/companies', label: 'Clients & Users' },
];
const utilityLinks = [
{ to: '/meeting-notes', label: 'Meeting Notes' },
{ to: '/invoices', label: 'Invoices & Expenses' },
{ to: '/survey-maker', label: 'Survey Maker' },
{ to: '/brand-book', label: 'Brand Book Maker' },
{ to: '/converters', label: 'Image Converter' },
{ to: '/fourge-passwords', label: 'Fourge Passwords' },
{ to: '/server-status', label: 'Server Status' },
];
return ( return (
<div className="sidebar-section"> <div className="sidebar-section">
{[ {primaryLinks.map(({ to, label }) => (
{ to: '/dashboard', label: 'Dashboard' }, <NavLink key={to} to={to} onClick={onNav} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
{ to: '/requests', label: 'Requests Inbox' }, {label}
{ to: '/brand-book', label: 'Brand Book (beta)' }, </NavLink>
{ to: '/invoices', label: 'Invoices' }, ))}
{ to: '/companies', label: 'Clients & Users' }, <div style={{ height: 1, margin: '10px 12px', background: 'var(--border)' }} />
].map(({ to, label }) => ( <div style={{ padding: '0 12px 8px', fontSize: 11, fontWeight: 700, letterSpacing: 0.8, textTransform: 'uppercase', color: 'var(--text-muted)' }}>
Team Tools
</div>
{utilityLinks.map(({ to, label }) => (
<NavLink key={to} to={to} onClick={onNav} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}> <NavLink key={to} to={to} onClick={onNav} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
{label} {label}
</NavLink> </NavLink>
@@ -26,6 +46,8 @@ function ClientNav({ onNav }) {
{[ {[
{ to: '/my-dashboard', label: 'Dashboard' }, { to: '/my-dashboard', label: 'Dashboard' },
{ to: '/my-projects', label: 'Projects' }, { to: '/my-projects', label: 'Projects' },
{ to: '/my-requests', label: 'Requests' },
{ to: '/file-sharing', label: 'File Sharing' },
{ to: '/my-invoices', label: 'Invoices' }, { to: '/my-invoices', label: 'Invoices' },
{ to: '/my-company', label: 'Company' }, { to: '/my-company', label: 'Company' },
].map(({ to, label }) => ( ].map(({ to, label }) => (
@@ -38,11 +60,33 @@ function ClientNav({ onNav }) {
} }
function ExternalNav({ onNav }) { function ExternalNav({ onNav }) {
const links = [
{ to: '/dashboard', label: 'Dashboard' },
{ to: '/assigned-requests', label: 'Requests' },
{ to: '/my-purchase-orders', label: 'Purchase Orders' },
{ to: '/file-sharing', label: 'File Sharing' },
{ to: '/survey-maker', label: 'Survey Maker' },
{ to: '/brand-book', label: 'Brand Book Maker' },
{ to: '/converters', label: 'Image Converter' },
];
return ( return (
<div className="sidebar-section"> <div className="sidebar-section">
<NavLink to="/dashboard" onClick={onNav} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}> {links.map(({ to, label }, index) => (
Dashboard <div key={to}>
</NavLink> {index === 2 && (
<>
<div style={{ height: 1, margin: '10px 12px', background: 'var(--border)' }} />
<div style={{ padding: '0 12px 8px', fontSize: 11, fontWeight: 700, letterSpacing: 0.8, textTransform: 'uppercase', color: 'var(--text-muted)' }}>
Team Tools
</div>
</>
)}
<NavLink to={to} onClick={onNav} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
{label}
</NavLink>
</div>
))}
</div> </div>
); );
} }
@@ -53,36 +97,58 @@ export default function Layout({ children }) {
const location = useLocation(); const location = useLocation();
const [theme, setTheme] = useState(() => localStorage.getItem('theme') || 'dark'); const [theme, setTheme] = useState(() => localStorage.getItem('theme') || 'dark');
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => localStorage.getItem('sidebarCollapsed') === 'true');
useEffect(() => { useEffect(() => {
document.documentElement.setAttribute('data-theme', theme); document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme); localStorage.setItem('theme', theme);
}, [theme]); }, [theme]);
// Close menu on route change useEffect(() => {
useEffect(() => { setMenuOpen(false); }, [location.pathname]); localStorage.setItem('sidebarCollapsed', String(sidebarCollapsed));
}, [sidebarCollapsed]);
// Close menu on route change (derived-state pattern, no effect needed)
const [lastPathname, setLastPathname] = useState(location.pathname);
if (lastPathname !== location.pathname) {
setLastPathname(location.pathname);
setMenuOpen(false);
}
const toggleTheme = () => setTheme(t => t === 'dark' ? 'light' : 'dark'); const toggleTheme = () => setTheme(t => t === 'dark' ? 'light' : 'dark');
const handleLogout = () => { logout(); navigate('/'); }; const handleLogout = async () => {
await logout();
navigate('/');
};
const initials = currentUser?.name const initials = currentUser?.name
?.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2); ?.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
return ( return (
<div className="app-layout"> <div className={`app-layout${sidebarCollapsed ? ' sidebar-collapsed' : ''}`}>
{/* Overlay */} {/* Overlay */}
{menuOpen && <div className="sidebar-overlay" onClick={() => setMenuOpen(false)} />} {menuOpen && <div className="sidebar-overlay" onClick={() => setMenuOpen(false)} />}
<aside className={`sidebar${menuOpen ? ' sidebar-open' : ''}`}> <aside className={`sidebar${menuOpen ? ' sidebar-open' : ''}`}>
<div className="sidebar-logo"> <div className="sidebar-logo">
<img src="/fourge-logo.png" alt="Fourge Branding" style={{ width: 140, display: 'block' }} /> <img className="brand-logo brand-logo-sidebar" src="/fourge-logo.png" alt="Fourge Branding" />
<button
className="sidebar-pin-toggle"
onClick={() => setSidebarCollapsed(current => !current)}
title={sidebarCollapsed ? 'Pin sidebar open' : 'Collapse sidebar'}
aria-label={sidebarCollapsed ? 'Pin sidebar open' : 'Collapse sidebar'}
>
{sidebarCollapsed ? '' : ''}
</button>
</div> </div>
{currentUser?.role === 'team' {!sidebarCollapsed && (
? <TeamNav onNav={() => setMenuOpen(false)} /> currentUser?.role === 'team'
: currentUser?.role === 'external' ? <TeamNav onNav={() => setMenuOpen(false)} />
? <ExternalNav onNav={() => setMenuOpen(false)} /> : currentUser?.role === 'external'
: <ClientNav onNav={() => setMenuOpen(false)} />} ? <ExternalNav onNav={() => setMenuOpen(false)} />
: <ClientNav onNav={() => setMenuOpen(false)} />
)}
<div className="sidebar-bottom"> <div className="sidebar-bottom">
<NavLink to="/settings" onClick={() => setMenuOpen(false)} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}> <NavLink to="/settings" onClick={() => setMenuOpen(false)} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
@@ -92,16 +158,12 @@ export default function Layout({ children }) {
<div className="sidebar-user-role">{currentUser?.role === 'external' ? 'Team' : currentUser?.role}</div> <div className="sidebar-user-role">{currentUser?.role === 'external' ? 'Team' : currentUser?.role}</div>
</div> </div>
</NavLink> </NavLink>
<div style={{ display: 'flex', alignItems: 'center', padding: '0 12px', gap: 8 }}> <div className="sidebar-bottom-actions">
<button className="sidebar-link" style={{ flex: 1 }} onClick={handleLogout}>Sign Out</button> {!sidebarCollapsed && <button className="sidebar-link" style={{ flex: 1 }} onClick={handleLogout}>Sign Out</button>}
<button <button
onClick={toggleTheme} onClick={toggleTheme}
className="sidebar-theme-toggle"
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'} title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
style={{
background: 'transparent', border: '1px solid #333', borderRadius: '6px',
padding: '7px 10px', cursor: 'pointer', color: '#888',
fontSize: 13, lineHeight: 1, transition: 'all 0.15s', flexShrink: 0,
}}
> >
{theme === 'dark' ? '☀' : '☾'} {theme === 'dark' ? '☀' : '☾'}
</button> </button>
@@ -115,6 +177,7 @@ export default function Layout({ children }) {
<button className="hamburger" onClick={() => setMenuOpen(o => !o)} aria-label="Menu"> <button className="hamburger" onClick={() => setMenuOpen(o => !o)} aria-label="Menu">
<span /><span /><span /> <span /><span /><span />
</button> </button>
<img className="brand-logo brand-logo-mobile" src="/fourge-logo.png" alt="Fourge Branding" />
</div> </div>
<main className="main-content"> <main className="main-content">
{children} {children}
+8
View File
@@ -0,0 +1,8 @@
export default function LoadingButton({ loading = false, disabled = false, children, loadingText, className = 'btn', type = 'button', ...props }) {
return (
<button type={type} className={className} disabled={disabled || loading} {...props}>
{loading && <span className="btn-spinner" aria-hidden="true" />}
<span>{loading ? (loadingText || children) : children}</span>
</button>
);
}
+15
View File
@@ -0,0 +1,15 @@
export default function PageLoader({ opacity = 0.6 }) {
return (
<div
style={{
minHeight: '100vh',
background: 'var(--bg, #111)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<img src="/fourge-logo.png" alt="Loading" style={{ width: 160, opacity }} />
</div>
);
}
+94 -39
View File
@@ -1,60 +1,121 @@
import { createContext, useContext, useState, useEffect } from 'react'; import { createContext, useContext, useState, useEffect, useEffectEvent } from 'react';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
import PageLoader from '../components/PageLoader';
const AuthContext = createContext(null); const AuthContext = createContext(null);
const PROFILE_CACHE_KEY = 'fourge_profile'; const PROFILE_CACHE_KEY = 'fourge_profile';
export function AuthProvider({ children }) { export function AuthProvider({ children }) {
const [currentUser, setCurrentUser] = useState(() => { const [currentUser, setCurrentUser] = useState(null);
// Seed from cache instantly — no loading flash for returning users
try {
const cached = localStorage.getItem(PROFILE_CACHE_KEY);
return cached ? JSON.parse(cached) : null;
} catch { return null; }
});
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const fetchAndCacheProfile = async (authUser) => { const fetchAndCacheProfile = async (authUser, attempt = 0) => {
try { try {
const { data } = await supabase const { data, error } = await Promise.race([
.from('profiles') supabase
.select('*, company:companies(id, name, phone, address)') .from('profiles')
.eq('id', authUser.id) .select('*, company:companies(id, name, phone, address)')
.single(); .eq('id', authUser.id)
.single(),
new Promise((_, reject) => setTimeout(() => reject(new Error('Profile fetch timeout')), 8000)),
]);
if (data) { if (data) {
const profile = { ...data, email: authUser.email }; const { data: memberships } = await supabase
.from('company_members')
.select('company:companies(id, name, phone, address)')
.eq('profile_id', authUser.id);
const companies = (memberships || [])
.map(membership => membership.company)
.filter(Boolean);
if (data.role === 'client' && data.company && !companies.some(company => company.id === data.company.id)) {
companies.unshift(data.company);
}
const profile = { ...data, email: authUser.email, companies };
setCurrentUser(profile); setCurrentUser(profile);
localStorage.setItem(PROFILE_CACHE_KEY, JSON.stringify(profile)); localStorage.setItem(PROFILE_CACHE_KEY, JSON.stringify(profile));
return profile;
} }
// Profile row not found yet (trigger race on first login) — retry once
if (error && attempt === 0) {
await new Promise(r => setTimeout(r, 800));
return fetchAndCacheProfile(authUser, 1);
}
console.error('Profile fetch failed:', error);
} catch (err) { } catch (err) {
console.error('Profile fetch failed:', err); console.error('Profile fetch failed:', err);
} }
return null;
}; };
useEffect(() => { const syncSessionUser = useEffectEvent(async (session) => {
// Fallback — stop blocking after 2s max if (session?.user) {
const timeout = setTimeout(() => setLoading(false), 2000); try {
let resolved = false; const cached = localStorage.getItem(PROFILE_CACHE_KEY);
if (cached && JSON.parse(cached)?.id !== session.user.id) {
const { data: { subscription } } = supabase.auth.onAuthStateChange(async (event, session) => { localStorage.removeItem(PROFILE_CACHE_KEY);
if (session?.user) { }
// Fetch fresh profile in background (cache already seeded above) } catch {
fetchAndCacheProfile(session.user);
} else {
setCurrentUser(null);
localStorage.removeItem(PROFILE_CACHE_KEY); localStorage.removeItem(PROFILE_CACHE_KEY);
} }
if (!resolved) { await fetchAndCacheProfile(session.user);
resolved = true; return;
clearTimeout(timeout); }
setLoading(false);
setCurrentUser(null);
localStorage.removeItem(PROFILE_CACHE_KEY);
});
useEffect(() => {
let active = true;
const bootstrap = async () => {
let hasCachedProfile = false;
// Show cached profile immediately so returning users aren't stuck on the loader
try {
const cached = localStorage.getItem(PROFILE_CACHE_KEY);
if (cached) {
const profile = JSON.parse(cached);
if (profile?.id && active) {
hasCachedProfile = true;
setCurrentUser(profile);
setLoading(false);
}
}
} catch { /* corrupt cache, ignore */ }
try {
// Timeout getSession — Edge can stall on this indefinitely
const sessionPromise = supabase.auth.getSession();
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Session timeout')), 6000)
);
const { data: { session } } = await Promise.race([sessionPromise, timeout]);
if (!active) return;
await syncSessionUser(session);
if (active) setLoading(false);
} catch (err) {
console.warn('Auth bootstrap failed:', err.message);
// If we have no cached user either, clear so they see the login page
if (active && !hasCachedProfile) {
setCurrentUser(null);
setLoading(false);
}
} }
};
bootstrap();
const { data: { subscription } } = supabase.auth.onAuthStateChange(async (_event, session) => {
if (!active) return;
await syncSessionUser(session);
if (active) setLoading(false);
}); });
return () => { return () => {
clearTimeout(timeout); active = false;
subscription.unsubscribe(); subscription.unsubscribe();
}; };
}, []); }, []);
@@ -83,14 +144,7 @@ export function AuthProvider({ children }) {
localStorage.removeItem(PROFILE_CACHE_KEY); localStorage.removeItem(PROFILE_CACHE_KEY);
}; };
if (loading) return ( if (loading) return <PageLoader />;
<div style={{
minHeight: '100vh', background: '#111111',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<img src="/fourge-logo.png" alt="Fourge Branding" style={{ width: 160, opacity: 0.6 }} />
</div>
);
return ( return (
<AuthContext.Provider value={{ currentUser, login, signup, logout }}> <AuthContext.Provider value={{ currentUser, login, signup, logout }}>
@@ -99,4 +153,5 @@ export function AuthProvider({ children }) {
); );
} }
// eslint-disable-next-line react-refresh/only-export-components
export const useAuth = () => useContext(AuthContext); export const useAuth = () => useContext(AuthContext);
+847 -3
View File
@@ -22,6 +22,8 @@
--text-secondary: #a8a8a8; --text-secondary: #a8a8a8;
--text-muted: #666666; --text-muted: #666666;
--border: #2a2a2a; --border: #2a2a2a;
--interactive-hover-border: #3a3a3a;
--interactive-row-hover: rgba(255,255,255,0.02);
--danger: #ef4444; --danger: #ef4444;
--success: #22c55e; --success: #22c55e;
} }
@@ -39,6 +41,8 @@
--text-secondary: #5a5a5a; --text-secondary: #5a5a5a;
--text-muted: #999999; --text-muted: #999999;
--border: #e0e0e0; --border: #e0e0e0;
--interactive-hover-border: #d0d0d0;
--interactive-row-hover: #fafafa;
} }
[data-theme="light"] input[type="text"], [data-theme="light"] input[type="text"],
[data-theme="light"] input[type="email"], [data-theme="light"] input[type="email"],
@@ -105,12 +109,46 @@ body {
height: 100vh; height: 100vh;
overflow-y: auto; overflow-y: auto;
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
transition: width 0.2s ease, min-width 0.2s ease;
} }
.sidebar-logo { .sidebar-logo {
padding: 0 20px 24px; padding: 0 20px 24px;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
margin-bottom: 16px; margin-bottom: 16px;
position: relative;
}
.brand-logo {
display: block;
height: auto;
}
.brand-logo-sidebar {
width: 140px;
}
.sidebar-pin-toggle {
position: absolute;
right: 12px;
top: 0;
width: 26px;
height: 26px;
border-radius: 6px;
border: 1px solid #333;
background: transparent;
color: var(--sidebar-text);
cursor: pointer;
font-size: 18px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
}
.sidebar-pin-toggle:hover {
background: var(--sidebar-hover-bg);
color: #fff;
}
.brand-logo-auth {
width: 200px;
margin: 0 auto 8px;
} }
.sidebar-logo h1 { font-size: 18px; font-weight: 700; color: #fff; letter-spacing: -0.3px; } .sidebar-logo h1 { font-size: 18px; font-weight: 700; color: #fff; letter-spacing: -0.3px; }
.sidebar-logo span { font-size: 12px; color: var(--sidebar-text); } .sidebar-logo span { font-size: 12px; color: var(--sidebar-text); }
@@ -138,6 +176,28 @@ body {
margin-top: auto; padding: 16px 12px 0; margin-top: auto; padding: 16px 12px 0;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
} }
.sidebar-bottom-actions {
display: flex;
align-items: center;
padding: 0 12px;
gap: 8px;
}
.sidebar-theme-toggle {
background: transparent;
border: 1px solid #333;
border-radius: 6px;
padding: 7px 10px;
cursor: pointer;
color: #888;
font-size: 13px;
line-height: 1;
transition: all 0.15s;
flex-shrink: 0;
}
.sidebar-theme-toggle:hover {
background: var(--sidebar-hover-bg);
color: #fff;
}
.sidebar-user { padding: 10px 12px; display: flex; align-items: center; gap: 10px; } .sidebar-user { padding: 10px 12px; display: flex; align-items: center; gap: 10px; }
.sidebar-avatar { .sidebar-avatar {
width: 30px; height: 30px; border-radius: 4px; width: 30px; height: 30px; border-radius: 4px;
@@ -150,8 +210,48 @@ body {
.sidebar-user-role { font-size: 11px; color: var(--sidebar-text); text-transform: capitalize; } .sidebar-user-role { font-size: 11px; color: var(--sidebar-text); text-transform: capitalize; }
.main-wrapper { margin-left: 240px; flex: 1; display: flex; flex-direction: column; min-height: 100vh; } .main-wrapper { margin-left: 240px; flex: 1; display: flex; flex-direction: column; min-height: 100vh; }
.main-wrapper { transition: margin-left 0.2s ease; }
.main-content { flex: 1; padding: 32px; } .main-content { flex: 1; padding: 32px; }
.app-layout.sidebar-collapsed .sidebar {
width: 76px;
min-width: 76px;
}
.app-layout.sidebar-collapsed .main-wrapper {
margin-left: 76px;
}
.app-layout.sidebar-collapsed .sidebar-logo {
padding: 0 12px 52px;
}
.app-layout.sidebar-collapsed .brand-logo-sidebar {
display: none;
}
.app-layout.sidebar-collapsed .sidebar-pin-toggle {
left: 50%;
right: auto;
top: 0;
transform: translateX(-50%);
}
.app-layout.sidebar-collapsed .sidebar-avatar {
margin: 0 auto;
}
.app-layout.sidebar-collapsed .sidebar-user-info {
display: none;
}
.app-layout.sidebar-collapsed .sidebar-bottom {
padding-left: 8px;
padding-right: 8px;
}
.app-layout.sidebar-collapsed .sidebar-bottom-actions {
justify-content: center;
padding: 8px 0 0;
}
.app-layout.sidebar-collapsed .sidebar-theme-toggle {
width: 32px;
height: 32px;
padding: 0;
}
/* Page header */ /* Page header */
.page-header { .page-header {
margin-bottom: 28px; display: flex; margin-bottom: 28px; display: flex;
@@ -163,13 +263,482 @@ body {
/* Cards */ /* Cards */
.card { background: var(--card-bg); border-radius: 8px; border: 1px solid var(--border); padding: 20px; } .card { background: var(--card-bg); border-radius: 8px; border: 1px solid var(--border); padding: 20px; }
.card-title { font-size: 14px; font-weight: 600; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 16px; } .card-title { font-size: 14px; font-weight: 600; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 16px; }
.page-toolbar {
margin-bottom: 24px;
}
.page-toolbar-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 18px;
}
.page-toolbar-section + .page-toolbar-section {
margin-top: 18px;
}
.page-toolbar-actions,
.page-toolbar-filters {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.page-toolbar-label {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-muted);
line-height: 1;
}
.page-toolbar-status {
font-size: 12px;
color: var(--text-muted);
margin-top: 10px;
}
/* Stats */ /* Stats */
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 28px; } .stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 28px; }
.stat-card { background: var(--card-bg); border-radius: 8px; border: 1px solid var(--border); padding: 20px; } .stat-card { background: var(--card-bg); border-radius: 8px; border: 1px solid var(--border); padding: 20px; }
.stat-card-highlight {
border-color: rgba(245, 165, 35, 0.45);
background:
linear-gradient(180deg, rgba(245, 165, 35, 0.12), rgba(245, 165, 35, 0.04)),
var(--card-bg);
box-shadow: inset 0 0 0 1px rgba(245, 165, 35, 0.08);
}
.stat-value { font-size: 32px; font-weight: 700; color: var(--text-primary); letter-spacing: -1px; } .stat-value { font-size: 32px; font-weight: 700; color: var(--text-primary); letter-spacing: -1px; }
.stat-label { font-size: 12px; color: var(--text-muted); margin-top: 4px; text-transform: uppercase; letter-spacing: 0.4px; } .stat-label { font-size: 12px; color: var(--text-muted); margin-top: 4px; text-transform: uppercase; letter-spacing: 0.4px; }
.stat-icon { display: none; } .stat-icon { display: none; }
.dashboard-chart-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 18px;
}
.dashboard-chart-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
}
.dashboard-chart-content {
display: grid;
grid-template-columns: 140px 1fr;
gap: 20px;
align-items: center;
}
.dashboard-pie-wrap {
display: flex;
align-items: center;
justify-content: center;
}
.dashboard-pie {
width: 132px;
height: 132px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.dashboard-pie::after {
content: '';
position: absolute;
inset: 18px;
border-radius: 50%;
background: var(--card-bg);
border: 1px solid var(--border);
}
.dashboard-pie-center {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
line-height: 1.1;
}
.dashboard-pie-center strong {
font-size: 22px;
color: var(--text-primary);
}
.dashboard-pie-center span {
margin-top: 4px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
}
.dashboard-legend {
display: flex;
flex-direction: column;
gap: 10px;
}
.dashboard-legend-item {
display: grid;
grid-template-columns: 10px 1fr auto;
gap: 10px;
align-items: center;
font-size: 13px;
color: var(--text-secondary);
}
.dashboard-legend-item strong {
color: var(--text-primary);
}
.dashboard-legend-dot {
width: 10px;
height: 10px;
border-radius: 999px;
}
.server-status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 18px;
}
.status-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 10px;
margin-top: 18px;
}
.status-stat-card,
.status-component {
border: 1px solid var(--border);
border-radius: 8px;
background: var(--card-bg-2);
padding: 12px 14px;
}
.status-stat-value {
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.3px;
}
.status-stat-label {
margin-top: 4px;
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.status-components-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 10px;
}
.status-meter {
border: 1px solid var(--border);
border-radius: 8px;
background: var(--card-bg-2);
padding: 14px 16px;
}
.status-meter-track {
margin: 12px 0 10px;
height: 8px;
border-radius: 999px;
background: rgba(255,255,255,0.06);
overflow: hidden;
}
.status-meter-fill {
height: 100%;
border-radius: inherit;
transition: width 0.2s ease;
}
.meeting-timeline {
display: grid;
gap: 18px;
}
.meeting-note-card {
display: grid;
grid-template-columns: 16px minmax(0, 1fr);
gap: 14px;
align-items: start;
}
.meeting-note-marker {
width: 12px;
height: 12px;
border-radius: 999px;
background: var(--accent);
margin-top: 8px;
box-shadow: 0 0 0 4px rgba(245, 165, 35, 0.12);
}
.meeting-note-content {
border: 1px solid var(--border);
border-radius: 8px;
background: var(--card-bg-2);
padding: 16px;
}
.meeting-note-header {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
}
.meeting-note-title {
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
}
.meeting-note-meta {
display: flex;
flex-wrap: wrap;
gap: 14px;
margin-top: 6px;
font-size: 12px;
color: var(--text-muted);
}
.meeting-note-body {
margin-top: 14px;
white-space: pre-wrap;
color: var(--text-primary);
font-size: 13px;
}
/* File Sharing */
.file-browser {
width: 100%;
max-width: none;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
position: relative;
}
.file-browser-dragging {
border-color: var(--accent);
}
.file-browser-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 14px;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
background: var(--card-bg);
}
.file-browser-breadcrumbs,
.file-browser-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.file-breadcrumb {
border: 1px solid var(--border);
background: var(--card-bg-2);
color: var(--text-primary);
border-radius: 6px;
padding: 7px 10px;
font-family: inherit;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.file-breadcrumb::before {
content: '/';
color: var(--text-muted);
margin-right: 6px;
}
.file-breadcrumb:hover {
border-color: var(--interactive-hover-border);
}
.file-browser-create {
display: grid;
grid-template-columns: minmax(180px, 340px) auto;
gap: 8px;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
background: var(--card-bg-2);
}
.file-upload-input {
display: none;
}
.file-list {
min-height: 0;
position: relative;
overflow-x: auto;
overflow-y: visible;
}
.file-row {
display: grid;
grid-template-columns: 34px minmax(260px, 1fr) minmax(90px, 120px) minmax(110px, 140px) minmax(170px, 190px);
gap: 10px;
align-items: center;
width: 100%;
min-width: 700px;
min-height: 52px;
padding: 0 16px;
border: 0;
border-bottom: 1px solid var(--border);
background: transparent;
color: var(--text-primary);
font-family: inherit;
text-align: left;
}
.file-row:last-child {
border-bottom: 0;
}
.file-row:not(.file-row-head):hover,
.file-row-button:hover {
background: var(--interactive-row-hover);
}
.file-row-head {
min-height: 38px;
color: var(--text-muted);
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.6px;
background: var(--card-bg);
}
.file-row-button {
cursor: pointer;
}
.file-icon {
width: 28px;
height: 28px;
border: 1px solid var(--border);
border-radius: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--accent);
background: var(--card-bg-2);
}
.file-name,
.file-name-button {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-primary);
font-size: 13px;
font-weight: 600;
}
.file-name-button {
border: 0;
background: transparent;
font-family: inherit;
text-align: left;
cursor: pointer;
}
.file-name-button:hover {
color: var(--accent);
}
.file-meta {
color: var(--text-secondary);
font-size: 12px;
}
.file-row-actions {
display: flex;
gap: 6px;
justify-content: flex-end;
}
.file-drop-overlay {
position: absolute;
inset: 0;
z-index: 8;
display: flex;
align-items: center;
justify-content: center;
background: rgba(17, 17, 17, 0.72);
border: 2px dashed var(--accent);
pointer-events: none;
}
.file-drop-panel {
min-width: 280px;
max-width: min(420px, calc(100% - 40px));
border: 1px solid rgba(245, 165, 35, 0.45);
border-radius: 8px;
background: var(--card-bg);
padding: 28px;
text-align: center;
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.28);
}
.file-drop-icon {
width: 46px;
height: 46px;
border-radius: 8px;
background: var(--accent);
color: #111;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: 800;
margin-bottom: 14px;
}
.file-drop-title {
font-size: 17px;
font-weight: 700;
color: var(--text-primary);
}
.file-drop-subtitle {
margin-top: 5px;
font-size: 13px;
color: var(--text-secondary);
}
@media (max-width: 760px) {
.file-browser-toolbar {
align-items: stretch;
flex-direction: column;
}
.file-browser-actions {
justify-content: flex-start;
}
.file-browser-create {
grid-template-columns: 1fr;
}
.file-row {
grid-template-columns: 34px minmax(220px, 1fr) 90px 110px 170px;
min-width: 700px;
}
}
/* Buttons */ /* Buttons */
.btn { .btn {
@@ -179,6 +748,28 @@ body {
transition: all 0.15s; text-decoration: none; white-space: nowrap; transition: all 0.15s; text-decoration: none; white-space: nowrap;
font-family: inherit; line-height: 1; font-family: inherit; line-height: 1;
} }
.btn:disabled {
opacity: 0.65;
cursor: not-allowed;
}
.btn-spinner {
width: 13px;
height: 13px;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
display: inline-block;
flex-shrink: 0;
animation: btn-spin 0.7s linear infinite;
}
.btn-sm .btn-spinner {
width: 11px;
height: 11px;
border-width: 1.5px;
}
@keyframes btn-spin {
to { transform: rotate(360deg); }
}
.btn-sm { padding: 5px 12px; font-size: 12px; line-height: 1; } .btn-sm { padding: 5px 12px; font-size: 12px; line-height: 1; }
.btn-primary { background: var(--accent); color: #111111; } .btn-primary { background: var(--accent); color: #111111; }
.btn-primary:hover { background: var(--accent-hover); } .btn-primary:hover { background: var(--accent-hover); }
@@ -286,11 +877,139 @@ select option { background: #222; color: #fff; }
.notification { padding: 12px 16px; border-radius: 6px; font-size: 13px; font-weight: 500; margin-bottom: 20px; } .notification { padding: 12px 16px; border-radius: 6px; font-size: 13px; font-weight: 500; margin-bottom: 20px; }
.notification-success { background: rgba(34,197,94,0.1); color: #4ade80; border: 1px solid rgba(34,197,94,0.2); } .notification-success { background: rgba(34,197,94,0.1); color: #4ade80; border: 1px solid rgba(34,197,94,0.2); }
.notification-info { background: rgba(37,99,235,0.1); color: #60a5fa; border: 1px solid rgba(37,99,235,0.2); } .notification-info { background: rgba(37,99,235,0.1); color: #60a5fa; border: 1px solid rgba(37,99,235,0.2); }
.request-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 20px; margin-bottom: 8px; transition: border-color 0.15s; } .request-card,
.request-card:hover { border-color: #3a3a3a; } .interactive-surface {
.request-card-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 10px; } transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
}
.request-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 12px 16px; margin-bottom: 0; }
.request-card:hover,
.interactive-surface:hover { border-color: var(--interactive-hover-border); }
.request-card:hover { background: var(--interactive-row-hover); }
.interactive-row,
.interactive-panel-toggle {
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.interactive-row:hover,
.interactive-panel-toggle:hover {
background: var(--interactive-row-hover);
}
.request-card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0; }
.request-card-title { font-size: 15px; font-weight: 600; color: var(--text-primary); } .request-card-title { font-size: 15px; font-weight: 600; color: var(--text-primary); }
.request-card-meta { font-size: 12px; color: var(--text-secondary); margin-top: 4px; } .request-card-meta { font-size: 12px; color: var(--text-secondary); margin-top: 4px; }
.request-title-inline-meta {
margin-left: 8px;
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
}
.request-card-detailed { display: flex; gap: 12px; align-items: center; }
.request-card-select { display: flex; align-items: center; }
.request-card-badges { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
.request-revision-chip {
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--card-bg-2);
color: var(--text-secondary);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.5px;
}
.request-column-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
font-size: 13px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--text-muted);
}
.request-section-header {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 0 12px;
background: transparent;
border: none;
font-family: inherit;
font-size: 13px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--text-muted);
cursor: pointer;
}
.request-section-body {
padding: 0 0 4px;
display: flex;
flex-direction: column;
gap: 14px;
}
.request-section-caret {
font-size: 11px;
color: var(--text-muted);
}
.request-project-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 4px 0 10px;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
}
.request-toolbar-card { margin-bottom: 24px; }
.request-toolbar-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 18px;
margin-top: 18px;
}
.request-toolbar-actions,
.request-filter-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.request-select-all-label {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-muted);
line-height: 1;
}
.request-toolbar-status {
font-size: 12px;
color: var(--text-muted);
margin-top: 10px;
}
.request-company-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 28px;
padding: 3px 8px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--card-bg);
color: var(--text-secondary);
font-size: 11px;
}
.request-empty-column {
color: var(--text-muted);
}
.action-buttons { display: flex; gap: 8px; flex-wrap: wrap; } .action-buttons { display: flex; gap: 8px; flex-wrap: wrap; }
.assign-select { padding: 6px 10px; border-radius: 6px; border: 1px solid var(--border); font-size: 13px; color: var(--text-primary); background: var(--card-bg-2); cursor: pointer; font-family: inherit; } .assign-select { padding: 6px 10px; border-radius: 6px; border: 1px solid var(--border); font-size: 13px; color: var(--text-primary); background: var(--card-bg-2); cursor: pointer; font-family: inherit; }
.assign-select option { background: #222; } .assign-select option { background: #222; }
@@ -309,10 +1028,110 @@ select option { background: #222; color: #fff; }
.text-muted { color: var(--text-muted); } .text-muted { color: var(--text-muted); }
.text-secondary { color: var(--text-secondary); } .text-secondary { color: var(--text-secondary); }
.client-summary-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.client-summary-tile {
padding: 14px 16px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--card-bg-2);
display: flex;
flex-direction: column;
gap: 4px;
}
.client-summary-tile strong {
font-size: 24px;
color: var(--text-primary);
letter-spacing: -0.5px;
}
.client-summary-tile span {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
}
.client-inline-empty {
padding: 20px 16px;
border: 1px dashed var(--border);
border-radius: 8px;
color: var(--text-muted);
font-size: 13px;
text-align: center;
}
.client-mini-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.client-mini-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--card-bg-2);
text-decoration: none;
}
.client-mini-row strong {
display: block;
font-size: 13px;
color: var(--text-primary);
}
.client-mini-row span {
display: block;
font-size: 11px;
color: var(--text-muted);
margin-top: 3px;
}
.client-project-card {
padding: 14px 16px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--card-bg);
display: flex;
align-items: center;
justify-content: space-between;
}
.client-project-card-title {
font-weight: 700;
font-size: 14px;
color: var(--text-primary);
}
.client-project-card-meta {
font-size: 12px;
color: var(--text-muted);
margin-top: 4px;
}
.client-project-highlight {
color: var(--accent);
font-weight: 700;
}
/* Mobile topbar — hidden on desktop */ /* Mobile topbar — hidden on desktop */
.mobile-topbar { .mobile-topbar {
display: none; display: none;
} }
.brand-logo-mobile {
width: 110px;
margin-left: 12px;
}
/* Hamburger button */ /* Hamburger button */
.hamburger { .hamburger {
@@ -345,8 +1164,28 @@ select option { background: #222; color: #fff; }
.sidebar { .sidebar {
position: fixed; left: -240px; top: 0; z-index: 200; position: fixed; left: -240px; top: 0; z-index: 200;
transition: left 0.25s ease; height: 100vh; transition: left 0.25s ease; height: 100vh;
width: 240px;
min-width: 240px;
} }
.sidebar.sidebar-open { left: 0; } .sidebar.sidebar-open { left: 0; }
.app-layout.sidebar-collapsed .sidebar {
width: 240px;
min-width: 240px;
}
.app-layout.sidebar-collapsed .brand-logo-sidebar {
width: 140px;
display: block;
}
.app-layout.sidebar-collapsed .sidebar-pin-toggle {
display: none;
}
.app-layout.sidebar-collapsed .sidebar-user-info {
display: block;
}
.app-layout.sidebar-collapsed .sidebar-bottom-actions {
justify-content: flex-start;
padding: 0 12px;
}
/* Show overlay when menu open */ /* Show overlay when menu open */
.sidebar-overlay { display: block; } .sidebar-overlay { display: block; }
@@ -375,6 +1214,11 @@ select option { background: #222; color: #fff; }
/* Request cards */ /* Request cards */
.request-card-header { flex-direction: column; gap: 8px; } .request-card-header { flex-direction: column; gap: 8px; }
.request-card-badges { justify-content: flex-start; }
.page-toolbar-grid { grid-template-columns: 1fr; }
.dashboard-chart-content { grid-template-columns: 1fr; }
.dashboard-pie-wrap { justify-content: flex-start; }
.client-summary-grid { grid-template-columns: 1fr; }
/* Auth card full width */ /* Auth card full width */
.auth-card { padding: 24px 20px; } .auth-card { padding: 24px 20px; }
+839
View File
@@ -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
View File
@@ -14,33 +14,252 @@ function formatDate(dateStr) {
return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }); 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 18; 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) { async function resolvePhoto(source) {
if (!source) return null; if (!source) return null;
if (source instanceof File) { if (source instanceof File || source instanceof Blob) {
return new Promise((resolve) => { const corrected = await correctOrientation(source);
const reader = new FileReader(); return ensurePdfSafeDataUrl(corrected, source.type === 'image/png');
reader.onload = (e) => resolve(e.target.result);
reader.onerror = () => resolve(null);
reader.readAsDataURL(source);
});
} }
if (typeof source === 'string') { if (typeof source === 'string') {
if (source.startsWith('data:')) return source; if (source.startsWith('data:')) {
return ensurePdfSafeDataUrl(source, getDataUrlMimeType(source) === 'image/png');
}
try { 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(); const blob = await resp.blob();
return new Promise((resolve) => { const corrected = await correctOrientation(blob);
const reader = new FileReader(); return ensurePdfSafeDataUrl(corrected, blob.type === 'image/png');
reader.onload = (e) => resolve(e.target.result);
reader.onerror = () => resolve(null);
reader.readAsDataURL(blob);
});
} catch { return null; } } catch { return null; }
} }
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) { function loadImage(src) {
return new Promise((resolve) => { return new Promise((resolve) => {
if (!src) return resolve(null); 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) { function addBolchozFooter(doc, pageNum, totalPages, clientLogoDataUrl, clientLogoImg) {
const logoY = H - MARGIN - BOLCHOZ_LOGO_H; const logoY = H - MARGIN - BOLCHOZ_LOGO_H;
const pgDivY = H - MARGIN - 3.5; // vertical center of 10pt text
let footerLogoW = 0; let footerLogoW = 0;
if (clientLogoDataUrl && clientLogoImg) { if (clientLogoDataUrl && clientLogoImg) {
const aspect = clientLogoImg.naturalWidth / clientLogoImg.naturalHeight; const aspect = clientLogoImg.naturalWidth / clientLogoImg.naturalHeight;
footerLogoW = BOLCHOZ_LOGO_H * aspect; 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')}`; 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.setTextColor(150, 150, 150);
doc.text(pgLabel, W - MARGIN, H - MARGIN, { align: 'right' }); 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) { export async function generateBrandBookEditorPDF(data) {
@@ -131,7 +340,7 @@ export async function generateBrandBookEditorPDF(data) {
clientContactName, clientContactEmail, clientContactPhone, clientContactName, clientContactEmail, clientContactPhone,
approvedDate, approvalNotes } = data; 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 // Load assets
const logo = await loadImage('/fourge-logo.png'); const logo = await loadImage('/fourge-logo.png');
@@ -147,14 +356,28 @@ export async function generateBrandBookEditorPDF(data) {
]); ]);
const clientLogoImg = clientLogoDataUrl ? await loadImage(clientLogoDataUrl) : null; const clientLogoImg = clientLogoDataUrl ? await loadImage(clientLogoDataUrl) : null;
const signPhotoDataUrls = await Promise.all(signs.map(s => resolvePhoto(s.photoSource))); 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 sitePhotoDataUrls = await Promise.all((sitePhotoSources || []).map(s => resolvePhoto(s)));
const validSitePhotos = sitePhotoDataUrls.filter(Boolean); const validSitePhotos = sitePhotoDataUrls.filter(Boolean);
const PHOTOS_PER_PAGE = 16; const PHOTOS_PER_PAGE = 16;
// Count pages // 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 let totalPages = 1; // cover
if (siteMapDataUrl) totalPages++; // site map page if (siteMapDataUrl) totalPages++; // site map page
totalPages++; // sign inventory totalPages++; // sign inventory
totalPages += extraInvPages; // overflow inventory pages
totalPages += signs.length; // sign pages totalPages += signs.length; // sign pages
totalPages += Math.ceil(validSitePhotos.length / PHOTOS_PER_PAGE); // site photo pages totalPages += Math.ceil(validSitePhotos.length / PHOTOS_PER_PAGE); // site photo pages
@@ -186,7 +409,7 @@ export async function generateBrandBookEditorPDF(data) {
let dW, dH; let dW, dH;
if (ratio >= 1) { dW = logoBoxSize; dH = dW / ratio; } if (ratio >= 1) { dW = logoBoxSize; dH = dW / ratio; }
else { dH = logoBoxSize; dW = dH * ratio; } else { dH = logoBoxSize; dW = dH * ratio; }
doc.addImage(projectLogoDataUrl, logoBoxX, logoBoxY, dW, dH); addDataUrlImage(doc, projectLogoDataUrl, logoBoxX, logoBoxY, dW, dH);
} }
} else { } else {
doc.setFontSize(9); doc.setFontSize(9);
@@ -238,18 +461,14 @@ export async function generateBrandBookEditorPDF(data) {
doc.setTextColor(30, 30, 30); doc.setTextColor(30, 30, 30);
if (revisionDate) doc.text(formatDate(revisionDate), rtx(formatDate(revisionDate)), ty); 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 halfW = (W - MARGIN * 2 - 20) / 2;
const rightColX = MARGIN + halfW + 20;
// ── Bottom left: Customer (anchored to bottom-left corner) ────────────────── // ── Bottom left: Customer (anchored to bottom-left corner) ──────────────────
const addrText = customerAddress || siteAddress || ''; const addrText = customerAddress || siteAddress || '';
const addrLineH = 11; const addrLineH = 11;
doc.setFontSize(9); doc.setFontSize(9);
doc.setFont('helvetica', 'normal'); doc.setFont('helvetica', 'normal');
const addrLines = addrText ? doc.splitTextToSize(addrText, halfW) : []; const addrLines = formatCoverAddress(addrText);
// Work bottom-up to anchor to H - MARGIN // Work bottom-up to anchor to H - MARGIN
const lY_addr = H - MARGIN; const lY_addr = H - MARGIN;
@@ -364,7 +583,7 @@ export async function generateBrandBookEditorPDF(data) {
dH = clientLogoH; dW = dH * ratio; dH = clientLogoH; dW = dH * ratio;
dx = clBoxLeft + (clientLogoW - dW) / 2; dy = clBoxTop; dx = clBoxLeft + (clientLogoW - dW) / 2; dy = clBoxTop;
} }
doc.addImage(clientLogoDataUrl, dx, dy, dW, dH); addDataUrlImage(doc, clientLogoDataUrl, dx, dy, dW, dH);
} }
} else { } else {
doc.setFontSize(8); doc.setFontSize(8);
@@ -390,6 +609,25 @@ export async function generateBrandBookEditorPDF(data) {
} }
const cY = H / 2; 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.setFontSize(9); doc.setFont('helvetica', 'bold');
doc.setTextColor(...ACCENT); doc.setCharSpace(3); doc.setTextColor(...ACCENT); doc.setCharSpace(3);
doc.text('BRAND BOOK', W / 2, cY - 50, { align: 'center' }); 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' }); 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 = []; const metaParts = [];
if (siteAddress) metaParts.push(siteAddress);
if (displayDate) metaParts.push(displayDate); if (displayDate) metaParts.push(displayDate);
if (preparedBy) metaParts.push(`Prepared by ${preparedBy}`); if (preparedBy) metaParts.push(`Prepared by ${preparedBy}`);
if (metaParts.length > 0) { if (metaParts.length > 0) {
doc.setFontSize(8); doc.setFont('helvetica', 'normal'); doc.setFontSize(8); doc.setFont('helvetica', 'normal');
doc.setTextColor(150, 150, 150); 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'); doc.setFontSize(9); doc.setFont('helvetica', 'bold');
@@ -428,17 +672,7 @@ export async function generateBrandBookEditorPDF(data) {
pageNum++; pageNum++;
doc.addPage(); doc.addPage();
if (template === 'bolchoz') { if (template === 'bolchoz') {
addBolchozFooter(doc, pageNum, totalPages, clientLogoDataUrl, clientLogoImg); const smLabelH = 18; // space below label before map
// "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 smTop = MARGIN + smLabelH; const smTop = MARGIN + smLabelH;
const smBottom = H - MARGIN - BOLCHOZ_FOOTER_H; const smBottom = H - MARGIN - BOLCHOZ_FOOTER_H;
const smW = W - MARGIN * 2; const smW = W - MARGIN * 2;
@@ -450,12 +684,29 @@ export async function generateBrandBookEditorPDF(data) {
const boxAspect = smW / smH; const boxAspect = smW / smH;
let dw, dh, dx, dy; let dw, dh, dx, dy;
if (aspect > boxAspect) { 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 { } 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 { } else {
addHeader(doc, logo, logoW, logoH, 'Site Map', pageNum, totalPages); addHeader(doc, logo, logoW, logoH, 'Site Map', pageNum, totalPages);
addFooter(doc, clientName, displayDate); addFooter(doc, clientName, displayDate);
@@ -475,7 +726,7 @@ export async function generateBrandBookEditorPDF(data) {
} }
doc.setDrawColor(200, 200, 200); doc.setLineWidth(0.4); doc.setDrawColor(200, 200, 200); doc.setLineWidth(0.4);
doc.rect(MARGIN, smTop, smW, smH); 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++; pageNum++;
doc.addPage(); 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 invContentBottom = template === 'bolchoz' ? H - MARGIN - BOLCHOZ_FOOTER_H : H - 30;
const invContentH = invContentBottom - invContentTop; const invContentH = invContentBottom - invContentTop;
@@ -513,7 +765,7 @@ export async function generateBrandBookEditorPDF(data) {
drawW = mapW; drawH = mapW / imgAspect; drawW = mapW; drawH = mapW / imgAspect;
drawX = mapX; drawY = mapY - (drawH - invContentH) / 2; 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' }); 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); addBolchozFooter(doc, pageNum, totalPages, clientLogoDataUrl, clientLogoImg);
} else { } else {
addHeader(doc, logo, logoW, logoH, 'Sign Inventory', pageNum, totalPages); addHeader(doc, logo, logoW, logoH, 'Sign Inventory', pageNum, totalPages);
@@ -548,7 +809,7 @@ export async function generateBrandBookEditorPDF(data) {
drawH = invContentH; drawW = invContentH * imgAspect; drawH = invContentH; drawW = invContentH * imgAspect;
drawX = mapX + (mapW - drawW) / 2; drawY = mapY; 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 { } else {
doc.setFontSize(14); 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 ─────────────────────────────────────────────────────────────── // ─── SIGN PAGES ───────────────────────────────────────────────────────────────
for (let i = 0; i < signs.length; i++) { 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 top = template === 'bolchoz' ? MARGIN : HEADER_H + 16;
const bottom = template === 'bolchoz' ? H - MARGIN - BOLCHOZ_FOOTER_H : H - 30; 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) { if (template === 'bolchoz') {
const photoImg = await loadImage(photoDataUrl); // ── Bolchoz sign page ──────────────────────────────────────────────────────
if (photoImg) { const TEAL = [80, 80, 80]; // dark gray divider
const imgAspect = photoImg.naturalWidth / photoImg.naturalHeight; const PHOTO_W = 288; // 4"
const boxAspect = photoW / availH; const PHOTO_H = 180; // 2.5"
let dw, dh, dx, dy; const photoX = W - MARGIN - PHOTO_W;
if (imgAspect > boxAspect) { const textW = photoX - MARGIN - 16;
dw = photoW; dh = photoW / imgAspect;
dx = MARGIN; dy = top + (availH - dh) / 2; // Diamond header
} else { const dr = 20;
dh = availH; dw = availH * imgAspect; const dcx = MARGIN + dr;
dx = MARGIN + (photoW - dw) / 2; dy = top; 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.setFillColor(245, 245, 245);
doc.rect(MARGIN, top, photoW, availH, 'F'); 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.setDrawColor(200, 200, 200);
doc.setLineWidth(0.5); doc.setLineWidth(0.5);
doc.rect(MARGIN, top, photoW, availH); doc.rect(MARGIN, top, photoW, availH);
} }
} else {
doc.setFillColor(245, 245, 245); let sy = top;
doc.rect(MARGIN, top, photoW, availH, 'F'); doc.setFillColor(...ACCENT);
doc.roundedRect(specsX, sy, 44, 18, 3, 3, 'F');
doc.setFontSize(10); 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.setFont('helvetica', 'bold');
doc.setTextColor(150, 150, 150); doc.setTextColor(...DARK);
doc.text(label.toUpperCase(), specsX, sy); doc.text(`#${sign.signNumber || (i + 1)}`, specsX + 22, sy + 12, { align: 'center' });
sy += 9; sy += 26;
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(16);
doc.setFontSize(7);
doc.setFont('helvetica', 'bold'); doc.setFont('helvetica', 'bold');
doc.setTextColor(150, 150, 150); doc.setTextColor(30, 30, 30);
doc.text('NOTES', specsX, sy); doc.text(sign.type || 'Sign Type', specsX, sy);
sy += 9; sy += 20;
doc.setFontSize(9);
doc.setFont('helvetica', 'normal'); doc.setDrawColor(...ACCENT);
doc.setTextColor(80, 80, 80); doc.setLineWidth(1);
const noteLines = doc.splitTextToSize(sign.notes, specsW); doc.line(specsX, sy, specsX + specsW, sy);
doc.text(noteLines, specsX, 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); 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 bottom = template === 'bolchoz' ? H - MARGIN - BOLCHOZ_FOOTER_H : H - 30;
const availW = W - MARGIN * 2; const availW = W - MARGIN * 2;
const thumbW = (availW - gapX * (cols - 1)) / cols; const thumbW = (availW - gapX * (cols - 1)) / cols;
@@ -704,34 +1098,30 @@ export async function generateBrandBookEditorPDF(data) {
const tx = MARGIN + col * (thumbW + gapX); const tx = MARGIN + col * (thumbW + gapX);
const ty = top + row * (thumbH + gapY); const ty = top + row * (thumbH + gapY);
doc.setFillColor(245, 245, 245); await drawCover(dataUrl, tx, ty, thumbW, thumbH, { quality: 0.72, pxPerPt: THUMB_PX_PER_PT });
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(200, 200, 200); doc.setDrawColor(200, 200, 200);
doc.setLineWidth(0.3); doc.setLineWidth(0.3);
doc.rect(tx, ty, thumbW, thumbH); 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 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`); 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' const colDefs = template === 'bolchoz'
? [ ? [
{ label: '#', flex: 0.5 }, { 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 cols = colDefs.map(c => ({ ...c, w: (c.flex / totalFlex) * w }));
const rowH = 18; const rowH = 18;
doc.setFillColor(...DARK); doc.setFillColor(120, 120, 120);
doc.rect(x, y, w, rowH, 'F'); doc.rect(x, y, w, rowH, 'F');
doc.setFontSize(7); doc.setFontSize(7);
doc.setFont('helvetica', 'bold'); doc.setFont('helvetica', 'bold');
doc.setTextColor(...ACCENT); doc.setTextColor(255, 255, 255);
let cx = x; let cx = x;
cols.forEach(col => { doc.text(col.label, cx + 5, y + 12); cx += col.w; }); cols.forEach(col => { doc.text(col.label, cx + 5, y + 12); cx += col.w; });
let ry = y + rowH; let ry = y + rowH;
signs.forEach((sign, i) => { let nextIndex = startIndex;
if (ry > y + h - rowH) return; for (let i = startIndex; i < signs.length; i++) {
if (ry + rowH > y + h) break;
const sign = signs[i];
const rowData = template === 'bolchoz' const rowData = template === 'bolchoz'
? [ ? [
sign.signNumber || String(i + 1), sign.signNumber || String(i + 1),
@@ -792,7 +1184,8 @@ function drawInventoryTable(doc, signs, x, y, w, h, template) {
doc.setLineWidth(0.2); doc.setLineWidth(0.2);
doc.line(x, ry + rowH, x + w, ry + rowH); doc.line(x, ry + rowH, x + w, ry + rowH);
ry += rowH; ry += rowH;
}); nextIndex = i + 1;
}
doc.setDrawColor(180, 180, 180); doc.setDrawColor(180, 180, 180);
doc.setLineWidth(0.5); doc.setLineWidth(0.5);
@@ -804,4 +1197,6 @@ function drawInventoryTable(doc, signs, x, y, w, h, template) {
doc.setTextColor(160, 160, 160); doc.setTextColor(160, 160, 160);
doc.text('No signs added yet', x + w / 2, y + rowH + 16, { align: 'center' }); doc.text('No signs added yet', x + w / 2, y + rowH + 16, { align: 'center' });
} }
return nextIndex;
} }
-546
View File
@@ -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`);
}
+49
View File
@@ -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',
});
}
+86 -2
View File
@@ -1,5 +1,45 @@
import { supabase } from './supabase'; 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. * Deletes all storage files (submissions + deliveries buckets) for the given task IDs.
* Call this before deleting tasks/projects/companies from the DB. * Call this before deleting tasks/projects/companies from the DB.
@@ -24,7 +64,7 @@ export async function cleanupTaskStorage(taskIds) {
.in('submission_id', subIds); .in('submission_id', subIds);
const subPaths = (subFiles || []).map(f => f.storage_path).filter(Boolean); 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) // Get deliveries (linked via submission_id, not task_id)
const { data: deliveries } = await supabase const { data: deliveries } = await supabase
@@ -41,7 +81,51 @@ export async function cleanupTaskStorage(taskIds) {
.in('delivery_id', delIds); .in('delivery_id', delIds);
const delPaths = (delFiles || []).map(f => f.storage_path).filter(Boolean); 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
View File
@@ -1,16 +1,88 @@
import { supabase } from './supabase'; import { supabase } from './supabase';
export async function sendEmail(type, to, data) { const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const { data: result, error } = await supabase.functions.invoke('send-email', { const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
body: { type, to, data },
}); async function getAccessToken() {
if (error) { const { data: sessionData } = await supabase.auth.getSession();
console.error('Email invoke error:', error); if (sessionData?.session?.access_token) return sessionData.session.access_token;
throw new Error(`Email failed: ${error.message || JSON.stringify(error)}`);
} const { data: refreshed, error: refreshError } = await supabase.auth.refreshSession();
if (result?.error) { if (refreshError) throw new Error(refreshError.message || 'Failed to refresh session');
console.error('Email send error:', result.error); if (!refreshed?.session?.access_token) throw new Error('No active session');
throw new Error(`Email failed: ${JSON.stringify(result.error)}`); return refreshed.session.access_token;
} }
return result;
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
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -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.
}
}
+118
View File
@@ -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.');
}
+19
View File
@@ -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;
}
-441
View File
@@ -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' });
}
}
+239
View File
@@ -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`);
}
+28
View File
@@ -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;
}
+13
View File
@@ -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);
}
}
+3 -3
View File
@@ -14,7 +14,7 @@ export default function Login() {
useEffect(() => { useEffect(() => {
if (currentUser) { if (currentUser) {
navigate(currentUser.role === 'team' ? '/dashboard' : '/my-dashboard', { replace: true }); navigate(currentUser.role === 'client' ? '/my-dashboard' : '/dashboard', { replace: true });
} }
}, [currentUser, navigate]); }, [currentUser, navigate]);
@@ -25,8 +25,8 @@ export default function Login() {
const { error: err } = await login(email, password); const { error: err } = await login(email, password);
if (err) { if (err) {
setError('Invalid email or password.'); setError('Invalid email or password.');
setLoading(false);
} }
setLoading(false);
// Navigation handled by useEffect watching currentUser // Navigation handled by useEffect watching currentUser
}; };
@@ -34,7 +34,7 @@ export default function Login() {
<div className="auth-page"> <div className="auth-page">
<div className="auth-card"> <div className="auth-card">
<div className="auth-logo"> <div className="auth-logo">
<img src="/fourge-logo.png" alt="Fourge Branding" style={{ width: 200, marginBottom: 8 }} /> <img className="brand-logo brand-logo-auth" src="/fourge-logo.png" alt="Fourge Branding" />
<p>Client & Project Portal</p> <p>Client & Project Portal</p>
</div> </div>
+153
View File
@@ -0,0 +1,153 @@
import { useState, useEffect } from 'react';
import { useParams, useSearchParams } from 'react-router-dom';
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
const currencyFormatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
});
export default function PayInvoice() {
const { id } = useParams();
const [searchParams] = useSearchParams();
const success = searchParams.get('success') === '1';
const cancelled = searchParams.get('cancelled') === '1';
const [invoice, setInvoice] = useState(null);
const [company, setCompany] = useState(null);
const [loading, setLoading] = useState(true);
const [paying, setPaying] = useState(false);
const [error, setError] = useState('');
const totalLabel = currencyFormatter.format(Number(invoice?.total || 0));
useEffect(() => {
async function load() {
try {
const response = await fetch(`${supabaseUrl}/functions/v1/get-public-invoice`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
apikey: supabaseAnonKey,
},
body: JSON.stringify({ invoice_ref: id }),
});
const body = await response.json();
if (!response.ok || !body?.invoice) {
setLoading(false);
return;
}
setInvoice(body.invoice);
setCompany(body.invoice.companies || null);
} catch (err) {
console.error('PayInvoice load failed:', err);
} finally {
setLoading(false);
}
}
load();
}, [id]);
const handlePay = async () => {
setPaying(true);
setError('');
try {
const response = await fetch(`${supabaseUrl}/functions/v1/create-checkout-session`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
apikey: supabaseAnonKey,
},
body: JSON.stringify({ invoice_ref: id }),
});
const body = await response.json();
if (!response.ok || !body?.url) throw new Error(body?.error || 'Could not create payment session.');
window.location.href = body.url;
} catch (err) {
setError(err.message);
setPaying(false);
}
};
return (
<div style={{ minHeight: '100vh', background: '#f5f5f5', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: 24 }}>
<div style={{ width: '100%', maxWidth: 480 }}>
{/* Logo */}
<div style={{ textAlign: 'center', marginBottom: 32 }}>
<img src="/fourge-logo.png" alt="Fourge Branding" style={{ height: 36, filter: 'invert(1)' }} />
</div>
{loading ? (
<div style={{ textAlign: 'center', color: '#666' }}>Loading...</div>
) : !invoice ? (
<div style={{ background: '#fff', color: '#141414', borderRadius: 12, padding: 32, textAlign: 'center', boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}>
<div style={{ fontSize: 18, fontWeight: 700, marginBottom: 8 }}>Invoice not found</div>
<div style={{ color: '#666' }}>This payment link may be invalid or expired.</div>
</div>
) : success || invoice.status === 'paid' ? (
<div style={{ background: '#fff', color: '#141414', borderRadius: 12, padding: 32, textAlign: 'center', boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}>
<div style={{ fontSize: 32, marginBottom: 12 }}></div>
<div style={{ fontSize: 20, fontWeight: 700, marginBottom: 8 }}>Payment received</div>
<div style={{ color: '#666', marginBottom: 4 }}>{invoice.invoice_number}</div>
<div style={{ fontSize: 24, fontWeight: 700, color: '#16a34a', marginTop: 16 }}>{totalLabel}</div>
<div style={{ color: '#666', marginTop: 6, fontSize: 12, letterSpacing: '0.3px' }}>Charged in USD</div>
<div style={{ color: '#666', marginTop: 8, fontSize: 13 }}>Thank you for your payment. We'll be in touch!</div>
</div>
) : (
<div style={{ background: '#fff', color: '#141414', borderRadius: 12, padding: 32, boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}>
<div style={{ fontSize: 13, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: '#999', marginBottom: 4 }}>Invoice</div>
<div style={{ fontSize: 22, fontWeight: 700, marginBottom: 4 }}>{invoice.invoice_number}</div>
<div style={{ color: '#666', marginBottom: 24 }}>{invoice.bill_to || company?.name}</div>
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '12px 0', borderTop: '1px solid #eee', borderBottom: '1px solid #eee', marginBottom: 24 }}>
<div>
<div style={{ fontSize: 12, color: '#999', marginBottom: 2 }}>Invoice Date</div>
<div style={{ fontWeight: 600, color: '#141414' }}>{new Date(invoice.invoice_date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 12, color: '#999', marginBottom: 2 }}>Due Date</div>
<div style={{ fontWeight: 600, color: '#141414' }}>{new Date(invoice.due_date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}</div>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<div style={{ fontSize: 14, color: '#666' }}>Total Due</div>
<div style={{ fontSize: 28, fontWeight: 700, color: '#141414' }}>{totalLabel}</div>
</div>
{cancelled && (
<div style={{ background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 8, padding: '10px 14px', fontSize: 13, color: '#dc2626', marginBottom: 16 }}>
Payment was cancelled. You can try again below.
</div>
)}
{error && (
<div style={{ background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 8, padding: '10px 14px', fontSize: 13, color: '#dc2626', marginBottom: 16 }}>
{error}
</div>
)}
<button
onClick={handlePay}
disabled={paying}
style={{
width: '100%', padding: '14px', borderRadius: 8, border: 'none',
background: paying ? '#999' : '#141414', color: '#fff',
fontSize: 16, fontWeight: 700, cursor: paying ? 'not-allowed' : 'pointer',
}}
>
{paying ? 'Redirecting to payment...' : `Pay ${totalLabel}`}
</button>
<div style={{ textAlign: 'center', marginTop: 16, fontSize: 12, color: '#999' }}>
Secured by Stripe · Charged in USD · hello@fourgebranding.com
</div>
</div>
)}
</div>
</div>
);
}
+18 -70
View File
@@ -5,47 +5,32 @@ import { useAuth } from '../context/AuthContext';
export default function Settings() { export default function Settings() {
const { currentUser } = useAuth(); const { currentUser } = useAuth();
const [form, setForm] = useState({ const [passwords, setPasswords] = useState({ next: '', confirm: '' });
name: currentUser?.name || '',
company: currentUser?.company || '',
});
const [passwords, setPasswords] = useState({ current: '', next: '', confirm: '' });
const [profileSaved, setProfileSaved] = useState(false);
const [passwordSaved, setPasswordSaved] = useState(false); const [passwordSaved, setPasswordSaved] = useState(false);
const [passwordError, setPasswordError] = useState(''); const [passwordError, setPasswordError] = useState('');
const [saving, setSaving] = useState(false);
const [savingPw, setSavingPw] = useState(false); const [savingPw, setSavingPw] = useState(false);
const set = (field) => (e) => setForm(f => ({ ...f, [field]: e.target.value }));
const setPw = (field) => (e) => setPasswords(p => ({ ...p, [field]: e.target.value })); const setPw = (field) => (e) => setPasswords(p => ({ ...p, [field]: e.target.value }));
const handleProfileSave = async (e) => {
e.preventDefault();
setSaving(true);
await supabase.from('profiles').update({
name: form.name.trim(),
company: form.company.trim(),
}).eq('id', currentUser.id);
setProfileSaved(true);
setSaving(false);
setTimeout(() => setProfileSaved(false), 3000);
};
const handlePasswordSave = async (e) => { const handlePasswordSave = async (e) => {
e.preventDefault(); e.preventDefault();
setPasswordError(''); setPasswordError('');
setPasswordSaved(false);
if (passwords.next !== passwords.confirm) { setPasswordError('New passwords do not match.'); return; } if (passwords.next !== passwords.confirm) { setPasswordError('New passwords do not match.'); return; }
if (passwords.next.length < 6) { setPasswordError('Password must be at least 6 characters.'); return; } if (passwords.next.length < 6) { setPasswordError('Password must be at least 6 characters.'); return; }
setSavingPw(true); setSavingPw(true);
const { error } = await supabase.auth.updateUser({ password: passwords.next }); try {
if (error) { setPasswordError(error.message); setSavingPw(false); return; } const { error } = await supabase.auth.updateUser({ password: passwords.next });
setPasswords({ current: '', next: '', confirm: '' }); if (error) { setPasswordError(error.message); return; }
setPasswordSaved(true); setPasswords({ next: '', confirm: '' });
setSavingPw(false); setPasswordSaved(true);
setTimeout(() => setPasswordSaved(false), 3000); setTimeout(() => setPasswordSaved(false), 3000);
} finally {
setSavingPw(false);
}
}; };
const initials = form.name const initials = (currentUser?.name || '')
.split(' ') .split(' ')
.map(n => n[0]) .map(n => n[0])
.join('') .join('')
@@ -56,63 +41,26 @@ export default function Settings() {
<Layout> <Layout>
<div className="page-header"> <div className="page-header">
<div> <div>
<div className="page-title">Profile & Settings</div> <div className="page-title">Settings</div>
<div className="page-subtitle">Update your name, company, and password.</div> <div className="page-subtitle">Your account info and password.</div>
</div> </div>
</div> </div>
<div style={{ maxWidth: 520, display: 'flex', flexDirection: 'column', gap: 24 }}> <div style={{ maxWidth: 520, display: 'flex', flexDirection: 'column', gap: 24 }}>
{/* Avatar preview */}
<div className="card" style={{ display: 'flex', alignItems: 'center', gap: 16 }}> <div className="card" style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<div className="sidebar-avatar" style={{ width: 56, height: 56, fontSize: 20, flexShrink: 0 }}> <div className="sidebar-avatar" style={{ width: 56, height: 56, fontSize: 20, flexShrink: 0 }}>
{initials || '?'} {initials || '?'}
</div> </div>
<div> <div>
<div style={{ fontWeight: 700, fontSize: 15 }}>{form.name || 'Your Name'}</div> <div style={{ fontWeight: 700, fontSize: 15 }}>{currentUser?.name || ''}</div>
<div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{currentUser?.email}</div> <div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{currentUser?.email}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2, textTransform: 'capitalize' }}>{currentUser?.role}</div> <div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2, textTransform: 'capitalize' }}>
{currentUser?.role}{currentUser?.company?.name ? ` · ${currentUser.company.name}` : ''}
</div>
</div> </div>
</div> </div>
{/* Profile form */}
<div className="card">
<div className="card-title">Profile Info</div>
<form onSubmit={handleProfileSave}>
<div className="form-group">
<label>Full Name *</label>
<input
type="text"
placeholder="First Last"
value={form.name}
onChange={set('name')}
required
/>
</div>
<div className="form-group">
<label>Company / Organization</label>
<input
type="text"
placeholder="Your company"
value={form.company}
onChange={set('company')}
/>
</div>
<div className="form-group">
<label>Email Address</label>
<input type="email" value={currentUser?.email} disabled style={{ opacity: 0.6, cursor: 'not-allowed' }} />
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 4 }}>Contact Fourge to change your email.</div>
</div>
{profileSaved && (
<div className="notification notification-success" style={{ marginBottom: 12 }}> Profile updated.</div>
)}
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? 'Saving...' : 'Save Changes'}
</button>
</form>
</div>
{/* Password form */}
<div className="card"> <div className="card">
<div className="card-title">Change Password</div> <div className="card-title">Change Password</div>
<form onSubmit={handlePasswordSave}> <form onSubmit={handlePasswordSave}>
-63
View File
@@ -1,63 +0,0 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
export default function Signup() {
const { signup } = useAuth();
const navigate = useNavigate();
const [form, setForm] = useState({ name: '', email: '', password: '', confirm: '' });
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const set = (field) => (e) => setForm(f => ({ ...f, [field]: e.target.value }));
const handleSubmit = async (e) => {
e.preventDefault();
if (form.password !== form.confirm) { setError('Passwords do not match.'); return; }
if (form.password.length < 6) { setError('Password must be at least 6 characters.'); return; }
setLoading(true);
setError('');
const { error: err } = await signup(form.email, form.password, form.name);
if (err) { setError(err); setLoading(false); return; }
navigate('/', { state: { message: 'Account created! Please sign in.' } });
};
return (
<div className="auth-page">
<div className="auth-card">
<div className="auth-logo">
<img src="/fourge-logo.png" alt="Fourge Branding" style={{ width: 180, marginBottom: 8 }} />
<p>Create your client account</p>
</div>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Full Name *</label>
<input type="text" placeholder="Jane Smith" value={form.name} onChange={set('name')} required />
</div>
<div className="form-group">
<label>Email Address *</label>
<input type="email" placeholder="jane@company.com" value={form.email} onChange={set('email')} required />
</div>
<div className="form-group">
<label>Password *</label>
<input type="password" placeholder="Min. 6 characters" value={form.password} onChange={set('password')} required />
</div>
<div className="form-group">
<label>Confirm Password *</label>
<input type="password" placeholder="Repeat password" value={form.confirm} onChange={set('confirm')} required />
</div>
{error && <p style={{ color: '#ef4444', fontSize: 13, marginBottom: 12 }}>{error}</p>}
<button type="submit" className="btn btn-primary w-full btn-lg" disabled={loading}>
{loading ? 'Creating account...' : 'Create Account'}
</button>
</form>
<p style={{ textAlign: 'center', marginTop: 20, fontSize: 13, color: '#a8a8a8' }}>
Already have an account?{' '}
<Link to="/" style={{ color: 'var(--accent)' }}>Sign in</Link>
</p>
</div>
</div>
);
}
-19
View File
@@ -1,19 +0,0 @@
import { Link } from 'react-router-dom';
export default function SignupConfirmation() {
return (
<div className="auth-page">
<div className="auth-card" style={{ textAlign: 'center' }}>
<img src="/fourge-logo.png" alt="Fourge Branding" style={{ width: 180, marginBottom: 24 }} />
<div style={{ fontSize: 48, marginBottom: 16 }}>📧</div>
<h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 8, color: '#fff' }}>Check your email</h2>
<p style={{ fontSize: 14, color: '#a8a8a8', marginBottom: 24, lineHeight: 1.6 }}>
We sent a confirmation link to your email address. Click it to activate your account, then come back to sign in.
</p>
<Link to="/" className="btn btn-primary w-full" style={{ justifyContent: 'center' }}>
Back to Sign In
</Link>
</div>
</div>
);
}
+183
View File
@@ -0,0 +1,183 @@
import { useEffect, useState } from 'react';
import Layout from '../../components/Layout';
import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext';
import { withTimeout } from '../../lib/withTimeout';
const poStatusColor = {
draft: 'not_started',
sent: 'in_progress',
approved: 'client_approved',
ready_to_pay: 'in_progress',
paid: 'client_approved',
cancelled: 'needs_revision',
};
const poStatusLabel = {
draft: 'Draft',
sent: 'Sent',
approved: 'Approved',
ready_to_pay: 'Ready to Pay',
paid: 'Paid',
cancelled: 'Cancelled',
};
export default function MyPurchaseOrders() {
const { currentUser } = useAuth();
const [purchaseOrders, setPurchaseOrders] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [savingId, setSavingId] = useState('');
useEffect(() => {
async function load() {
if (!currentUser?.id) {
setLoading(false);
return;
}
try {
const { data, error: loadError } = await withTimeout(
supabase
.from('subcontractor_payments')
.select('*, project:projects(id, name, company:companies(name)), items:subcontractor_po_items(*, task:tasks(id, title))')
.eq('profile_id', currentUser.id)
.order('date', { ascending: false }),
12000,
'Purchase orders load'
);
if (loadError) {
console.error('Failed to load purchase orders:', loadError);
setError(loadError.message || 'Failed to load purchase orders.');
setPurchaseOrders([]);
} else {
setPurchaseOrders(data || []);
setError('');
}
} catch (error) {
console.error('Purchase orders load failed:', error);
setError(error.message || 'Failed to load purchase orders.');
setPurchaseOrders([]);
} finally {
setLoading(false);
}
}
load();
}, [currentUser?.id]);
const handleApprove = async (po) => {
setSavingId(po.id);
const { data, error: approveError } = await supabase
.from('subcontractor_payments')
.update({ status: 'approved', approved_at: new Date().toISOString() })
.eq('id', po.id)
.eq('profile_id', currentUser.id)
.select('*, project:projects(id, name, company:companies(name)), items:subcontractor_po_items(*, task:tasks(id, title))')
.single();
if (approveError) {
alert(`Failed to approve PO: ${approveError.message}`);
} else if (data) {
setPurchaseOrders(prev => prev.map(row => row.id === po.id ? data : row));
}
setSavingId('');
};
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">Purchase Orders</div>
<div className="page-subtitle">Review and approve subcontractor work orders assigned to you.</div>
</div>
</div>
{loading ? (
<p style={{ color: 'var(--text-muted)' }}>Loading...</p>
) : error ? (
<div className="card" style={{ color: 'var(--danger)' }}>{error}</div>
) : purchaseOrders.length === 0 ? (
<div className="empty-state">
<h3>No purchase orders</h3>
<p>New POs will appear here when the Fourge team sends them.</p>
</div>
) : (
<div style={{ display: 'grid', gap: 12 }}>
{purchaseOrders.map(po => (
<div key={po.id} className="card">
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16, marginBottom: 12 }}>
<div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', fontWeight: 700, textTransform: 'uppercase' }}>
{po.po_number || 'Purchase Order'}
</div>
<div className="card-title" style={{ marginBottom: 4 }}>{po.project?.name || 'Subcontractor Work'}</div>
<div style={{ color: 'var(--text-muted)', fontSize: 13 }}>
{po.project?.company?.name || 'Fourge Branding'} · {new Date(po.date).toLocaleDateString()}
{po.due_date ? ` · Due ${new Date(po.due_date).toLocaleDateString()}` : ''}
</div>
</div>
<span className={`badge badge-${poStatusColor[po.status] || 'not_started'}`}>
{poStatusLabel[po.status] || po.status}
</span>
</div>
<div style={{ display: 'grid', gap: 10 }}>
<div>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', marginBottom: 4 }}>Scope</div>
<div style={{ whiteSpace: 'pre-wrap' }}>{po.description}</div>
</div>
{po.items?.length > 0 && (
<div>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', marginBottom: 4 }}>Line Items</div>
<div style={{ border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden' }}>
{po.items
.slice()
.sort((a, b) => Number(a.sort_order || 0) - Number(b.sort_order || 0))
.map(item => (
<div key={item.id} style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 12, padding: '10px 12px', borderBottom: '1px solid var(--border)' }}>
<div>
<div style={{ fontWeight: 700 }}>{item.description || item.task?.title}</div>
{item.task?.title && item.description !== item.task.title && (
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>{item.task.title}</div>
)}
</div>
<div style={{ fontWeight: 800 }}>${Number(item.amount).toFixed(2)}</div>
</div>
))}
</div>
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 10 }}>
<div>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase' }}>Amount</div>
<div style={{ fontWeight: 800 }}>${Number(po.amount).toFixed(2)}</div>
</div>
<div>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase' }}>Terms</div>
<div>{po.terms || 'Net 15'}</div>
</div>
<div>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase' }}>Paid</div>
<div>{po.paid_at ? new Date(po.paid_at).toLocaleDateString() : 'Not paid'}</div>
</div>
</div>
{po.notes && (
<div style={{ color: 'var(--text-muted)', fontSize: 13 }}>{po.notes}</div>
)}
</div>
{po.status === 'sent' && (
<div style={{ marginTop: 14, display: 'flex', justifyContent: 'flex-end' }}>
<button className="btn btn-primary btn-sm" onClick={() => handleApprove(po)} disabled={savingId === po.id}>
{savingId === po.id ? 'Approving...' : 'Approve PO'}
</button>
</div>
)}
</div>
))}
</div>
)}
</Layout>
);
}
+3002 -268
View File
File diff suppressed because it is too large Load Diff
-505
View File
@@ -1,505 +0,0 @@
import { useState, useEffect, useRef } from 'react';
import Layout from '../../components/Layout';
import { supabase } from '../../lib/supabase';
import { generateBrandBookPDF } from '../../lib/brandbook';
const DEFAULT_COLORS = [
{ name: 'Primary', hex: '#1a1a1a' },
{ name: 'Accent', hex: '#F5A523' },
{ name: 'White', hex: '#ffffff' },
];
const EMPTY_FORM = {
// Cover page
selectedCompanyId: '',
projectLogoDataUrl: null,
creationDate: new Date().toISOString().slice(0, 10),
revisionDate: '',
customerName: '',
streetAddress: '',
clientLogoUrl: '',
clientContactName: '',
clientContactEmail: '',
clientContactPhone: '',
approvedDate: '',
approvalNotes: '',
// Brand identity
companyName: '',
brandName: '',
tagline: '',
brandStory: '',
brandValues: '',
colors: DEFAULT_COLORS,
primaryFont: '',
secondaryFont: '',
fontNotes: '',
brandVoice: '',
brandAdjectives: '',
logoNotes: '',
dos: '',
donts: '',
};
export default function BrandBook() {
const [companies, setCompanies] = useState([]);
const [form, setForm] = useState(EMPTY_FORM);
const [generating, setGenerating] = useState(false);
const [notification, setNotification] = useState(null);
const [uploadingClientLogo, setUploadingClientLogo] = useState(false);
const [savingClientInfo, setSavingClientInfo] = useState(false);
const [clientInfoSaved, setClientInfoSaved] = useState(false);
const projectLogoRef = useRef();
const clientLogoRef = useRef();
useEffect(() => {
supabase.from('companies').select('id, name').order('name').then(({ data }) => setCompanies(data || []));
}, []);
const set = (field) => (e) => setForm(f => ({ ...f, [field]: e.target.value }));
const handleCompanyChange = async (e) => {
const companyId = e.target.value;
if (!companyId) {
setForm(f => ({ ...f, selectedCompanyId: '', companyName: '', customerName: '', streetAddress: '', clientLogoUrl: '', clientContactName: '', clientContactEmail: '', clientContactPhone: '' }));
return;
}
const { data: co } = await supabase.from('companies').select('*').eq('id', companyId).single();
if (co) {
setForm(f => ({
...f,
selectedCompanyId: companyId,
companyName: co.name,
customerName: f.customerName || co.name,
streetAddress: co.address || f.streetAddress,
clientLogoUrl: co.client_logo_url || '',
clientContactName: co.contact_name || '',
clientContactEmail: co.contact_email || '',
clientContactPhone: co.contact_phone || '',
}));
}
};
const handleProjectLogoUpload = (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => setForm(f => ({ ...f, projectLogoDataUrl: ev.target.result }));
reader.readAsDataURL(file);
};
const handleClientLogoUpload = async (e) => {
const file = e.target.files[0];
if (!file) return;
if (!form.selectedCompanyId) {
setNotification({ type: 'error', msg: 'Select a company before uploading their logo.' });
return;
}
setUploadingClientLogo(true);
const ext = file.name.split('.').pop().toLowerCase();
const path = `${form.selectedCompanyId}/logo.${ext}`;
await supabase.storage.from('company-logos').remove([path]);
const { error } = await supabase.storage.from('company-logos').upload(path, file, { upsert: true });
if (!error) {
const { data: { publicUrl } } = supabase.storage.from('company-logos').getPublicUrl(path);
await supabase.from('companies').update({ client_logo_url: publicUrl }).eq('id', form.selectedCompanyId);
setForm(f => ({ ...f, clientLogoUrl: publicUrl }));
} else {
setNotification({ type: 'error', msg: 'Failed to upload client logo.' });
}
setUploadingClientLogo(false);
};
const handleSaveClientInfo = async () => {
if (!form.selectedCompanyId) return;
setSavingClientInfo(true);
await supabase.from('companies').update({
address: form.streetAddress || null,
contact_name: form.clientContactName || null,
contact_email: form.clientContactEmail || null,
contact_phone: form.clientContactPhone || null,
}).eq('id', form.selectedCompanyId);
setSavingClientInfo(false);
setClientInfoSaved(true);
setTimeout(() => setClientInfoSaved(false), 2500);
};
const setColor = (i, field, value) => {
setForm(f => ({
...f,
colors: f.colors.map((c, idx) => idx === i ? { ...c, [field]: value } : c),
}));
};
const addColor = () => setForm(f => ({ ...f, colors: [...f.colors, { name: '', hex: '#cccccc' }] }));
const removeColor = (i) => setForm(f => ({ ...f, colors: f.colors.filter((_, idx) => idx !== i) }));
const handleGenerate = async () => {
if (!form.brandName.trim()) {
setNotification({ type: 'error', msg: 'Brand name is required.' });
return;
}
setGenerating(true);
setNotification(null);
try {
await generateBrandBookPDF(form);
setNotification({ type: 'success', msg: '✓ Brand book PDF downloaded!' });
} catch (err) {
setNotification({ type: 'error', msg: `Failed to generate PDF: ${err.message}` });
}
setGenerating(false);
};
const handleReset = () => {
if (window.confirm('Clear all fields and start over?')) {
setForm(EMPTY_FORM);
setNotification(null);
}
};
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">Brand Book <span style={{ fontSize: 13, fontWeight: 500, color: 'var(--accent)', marginLeft: 8, padding: '2px 8px', border: '1px solid var(--accent)', borderRadius: 4 }}>beta</span></div>
<div className="page-subtitle">Fill in the brand details below and generate a PDF brand book.</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-outline btn-sm" onClick={handleReset}>Reset</button>
<button className="btn btn-primary btn-sm" onClick={handleGenerate} disabled={generating}>
{generating ? 'Generating...' : '⬇ Generate PDF'}
</button>
</div>
</div>
{notification && (
<div className={`notification ${notification.type === 'error' ? 'notification-error' : 'notification-success'}`} style={{ marginBottom: 24 }}>
{notification.msg}
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
{/* ── COVER PAGE ─────────────────────────────────────────────────────────── */}
<div className="card">
<div className="card-title">Cover Page</div>
{/* Company + Brand Name */}
<div className="grid-2">
<div className="form-group">
<label>Client Company</label>
<select value={form.selectedCompanyId} onChange={handleCompanyChange}>
<option value=""> Select company </option>
{companies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div className="form-group">
<label>Brand Name *</label>
<input type="text" placeholder="e.g. Acme Corp" value={form.brandName} onChange={set('brandName')} />
</div>
</div>
{/* Project Logo */}
<div className="form-group">
<label>Project Logo <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(top left, 5"×5" area)</span></label>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{form.projectLogoDataUrl && (
<img
src={form.projectLogoDataUrl}
alt="Project logo preview"
style={{ maxHeight: 60, maxWidth: 120, objectFit: 'contain', border: '1px solid var(--border)', borderRadius: 6, padding: 4, background: '#fff' }}
/>
)}
<div>
<button className="btn btn-outline btn-sm" onClick={() => projectLogoRef.current?.click()}>
{form.projectLogoDataUrl ? 'Replace Logo' : 'Upload Logo'}
</button>
{form.projectLogoDataUrl && (
<button className="btn btn-outline btn-sm" style={{ marginLeft: 8, color: 'var(--danger)', borderColor: 'var(--danger)' }} onClick={() => setForm(f => ({ ...f, projectLogoDataUrl: null }))}>
Remove
</button>
)}
<input ref={projectLogoRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleProjectLogoUpload} />
</div>
</div>
</div>
{/* Dates */}
<div className="grid-2">
<div className="form-group">
<label>Creation Date</label>
<input type="date" value={form.creationDate} onChange={set('creationDate')} />
</div>
<div className="form-group">
<label>Revision Date <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
<input type="date" value={form.revisionDate} onChange={set('revisionDate')} />
</div>
</div>
{/* Customer info */}
<div className="grid-2">
<div className="form-group">
<label>Customer Name</label>
<input type="text" placeholder="e.g. Acme Corp" value={form.customerName} onChange={set('customerName')} />
</div>
<div className="form-group">
<label>Street Address <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
<input type="text" placeholder="e.g. 123 Main St, City, State 00000" value={form.streetAddress} onChange={set('streetAddress')} />
</div>
</div>
{/* Divider */}
<div style={{ borderTop: '1px solid var(--border)', margin: '8px 0 20px' }} />
{/* Client info (saved per company) */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>Client Info</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>Saved to the selected company reused across all brand books.</div>
</div>
{form.selectedCompanyId && (
<button className="btn btn-outline btn-sm" onClick={handleSaveClientInfo} disabled={savingClientInfo}>
{savingClientInfo ? 'Saving...' : clientInfoSaved ? '✓ Saved' : 'Save to Company'}
</button>
)}
</div>
{/* Client logo */}
<div className="form-group">
<label>Client Logo <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(3.5"×1.5" area, bottom right)</span></label>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{form.clientLogoUrl && (
<img
src={form.clientLogoUrl}
alt="Client logo preview"
style={{ maxHeight: 50, maxWidth: 140, objectFit: 'contain', border: '1px solid var(--border)', borderRadius: 6, padding: 4, background: '#fff' }}
/>
)}
<div>
<button
className="btn btn-outline btn-sm"
onClick={() => clientLogoRef.current?.click()}
disabled={uploadingClientLogo}
>
{uploadingClientLogo ? 'Uploading...' : form.clientLogoUrl ? 'Replace Logo' : 'Upload Logo'}
</button>
{!form.selectedCompanyId && (
<span style={{ marginLeft: 10, fontSize: 12, color: 'var(--text-muted)' }}>Select a company first</span>
)}
<input ref={clientLogoRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleClientLogoUpload} />
</div>
</div>
</div>
<div className="grid-2" style={{ marginTop: 4 }}>
<div className="form-group">
<label>Contact Name</label>
<input type="text" placeholder="e.g. Jane Smith" value={form.clientContactName} onChange={set('clientContactName')} />
</div>
<div className="form-group">
<label>Email</label>
<input type="email" placeholder="e.g. jane@client.com" value={form.clientContactEmail} onChange={set('clientContactEmail')} />
</div>
</div>
<div className="form-group" style={{ maxWidth: 320 }}>
<label>Phone</label>
<input type="text" placeholder="e.g. (555) 000-0000" value={form.clientContactPhone} onChange={set('clientContactPhone')} />
</div>
{/* Divider */}
<div style={{ borderTop: '1px solid var(--border)', margin: '8px 0 20px' }} />
{/* Approval */}
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 14 }}>Approval</div>
<div className="grid-2">
<div className="form-group">
<label>Approved Date <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
<input type="date" value={form.approvedDate} onChange={set('approvedDate')} />
</div>
</div>
<div className="form-group">
<label>Notes <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
<textarea
placeholder="Any approval notes or conditions..."
value={form.approvalNotes}
onChange={set('approvalNotes')}
style={{ minHeight: 70 }}
/>
</div>
</div>
{/* ── BRAND IDENTITY ─────────────────────────────────────────────────────── */}
<div className="card">
<div className="card-title">Brand Identity</div>
<div className="form-group">
<label>Tagline <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
<input type="text" placeholder="e.g. Built for the bold." value={form.tagline} onChange={set('tagline')} />
</div>
</div>
{/* ── BRAND STORY ────────────────────────────────────────────────────────── */}
<div className="card">
<div className="card-title">Brand Story & Values</div>
<div className="form-group">
<label>Brand Story</label>
<textarea
placeholder="Describe the brand — its origin, mission, and what makes it unique..."
value={form.brandStory}
onChange={set('brandStory')}
style={{ minHeight: 120 }}
/>
</div>
<div className="form-group">
<label>
Brand Values
<span style={{ fontWeight: 400, color: 'var(--text-muted)', marginLeft: 6 }}>one per line</span>
</label>
<textarea
placeholder={"Innovation\nAuthenticity\nCommunity"}
value={form.brandValues}
onChange={set('brandValues')}
style={{ minHeight: 80 }}
/>
</div>
</div>
{/* ── COLOR PALETTE ──────────────────────────────────────────────────────── */}
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<div className="card-title" style={{ margin: 0 }}>Color Palette</div>
<button className="btn btn-outline btn-sm" onClick={addColor}>+ Add Color</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{form.colors.map((color, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<input
type="color"
value={color.hex}
onChange={e => setColor(i, 'hex', e.target.value)}
style={{ width: 44, height: 38, padding: 2, borderRadius: 6, border: '1px solid var(--border)', cursor: 'pointer', background: 'none' }}
/>
<input
type="text"
value={color.hex}
onChange={e => setColor(i, 'hex', e.target.value)}
placeholder="#000000"
style={{ width: 100, margin: 0, fontFamily: 'monospace', fontSize: 13 }}
/>
<input
type="text"
value={color.name}
onChange={e => setColor(i, 'name', e.target.value)}
placeholder="Color name"
style={{ flex: 1, margin: 0 }}
/>
<div style={{ width: 38, height: 38, borderRadius: 6, background: color.hex, border: '1px solid var(--border)', flexShrink: 0 }} />
<button
type="button"
onClick={() => removeColor(i)}
style={{ background: 'none', border: 'none', color: 'var(--danger)', cursor: 'pointer', fontSize: 16, padding: '0 4px', flexShrink: 0 }}
></button>
</div>
))}
</div>
</div>
{/* ── TYPOGRAPHY ─────────────────────────────────────────────────────────── */}
<div className="card">
<div className="card-title">Typography</div>
<div className="grid-2">
<div className="form-group">
<label>Primary Font</label>
<input type="text" placeholder="e.g. Neue Haas Grotesk" value={form.primaryFont} onChange={set('primaryFont')} />
</div>
<div className="form-group">
<label>Secondary Font</label>
<input type="text" placeholder="e.g. Freight Text Pro" value={form.secondaryFont} onChange={set('secondaryFont')} />
</div>
</div>
<div className="form-group">
<label>Usage Notes</label>
<textarea
placeholder="e.g. Primary font used for headings and UI. Secondary font for body copy and long-form text..."
value={form.fontNotes}
onChange={set('fontNotes')}
style={{ minHeight: 80 }}
/>
</div>
</div>
{/* ── BRAND VOICE ────────────────────────────────────────────────────────── */}
<div className="card">
<div className="card-title">Brand Voice & Tone</div>
<div className="form-group">
<label>Voice Description</label>
<textarea
placeholder="Describe how the brand communicates — its personality, tone, and style of writing..."
value={form.brandVoice}
onChange={set('brandVoice')}
style={{ minHeight: 100 }}
/>
</div>
<div className="form-group">
<label>
Brand Adjectives
<span style={{ fontWeight: 400, color: 'var(--text-muted)', marginLeft: 6 }}>comma separated</span>
</label>
<input
type="text"
placeholder="e.g. Bold, Approachable, Modern, Trustworthy"
value={form.brandAdjectives}
onChange={set('brandAdjectives')}
/>
</div>
</div>
{/* ── LOGO GUIDELINES ────────────────────────────────────────────────────── */}
<div className="card">
<div className="card-title">Logo Usage Guidelines</div>
<div className="form-group">
<label>Guidelines</label>
<textarea
placeholder="e.g. Always use the full-color logo on light backgrounds. Use the white version on dark backgrounds. Minimum size 40px. Never stretch, rotate, or recolor the logo..."
value={form.logoNotes}
onChange={set('logoNotes')}
style={{ minHeight: 100 }}
/>
</div>
</div>
{/* ── DO'S & DON'TS ──────────────────────────────────────────────────────── */}
<div className="card">
<div className="card-title">Do's & Don'ts</div>
<div className="grid-2">
<div className="form-group">
<label style={{ color: '#16a34a' }}> Do's <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>one per line</span></label>
<textarea
placeholder={"Use brand colors consistently\nMaintain clear space around the logo\nUse approved fonts only"}
value={form.dos}
onChange={set('dos')}
style={{ minHeight: 120 }}
/>
</div>
<div className="form-group">
<label style={{ color: '#dc2626' }}>✕ Don'ts <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>one per line</span></label>
<textarea
placeholder={"Don't alter the logo colors\nDon't use unapproved fonts\nDon't place logo on busy backgrounds"}
value={form.donts}
onChange={set('donts')}
style={{ minHeight: 120 }}
/>
</div>
</div>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 24, paddingBottom: 40 }}>
<button className="btn btn-outline" onClick={handleReset}>Reset</button>
<button className="btn btn-primary btn-lg" onClick={handleGenerate} disabled={generating}>
{generating ? 'Generating...' : '⬇ Generate Brand Book PDF'}
</button>
</div>
</Layout>
);
}
+472 -150
View File
@@ -1,16 +1,26 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout'; import Layout from '../../components/Layout';
import { supabase } from '../../lib/supabase'; import { supabase } from '../../lib/supabase';
import { cleanupTaskStorage } from '../../lib/deleteHelpers'; import { deleteCompanyData } from '../../lib/deleteHelpers';
import { restoreCompanyArchive } from '../../lib/archiveHelpers';
import { readPageCache, writePageCache } from '../../lib/pageCache';
import { syncSeafileFolders } from '../../lib/seafileFolders';
function getRoleLabel(role) {
if (role === 'external') return 'Subcontractor';
if (role === 'client') return 'Client';
if (role === 'team') return 'Team';
return role || '—';
}
export default function Companies() { export default function Companies() {
const navigate = useNavigate(); const navigate = useNavigate();
const [companies, setCompanies] = useState([]); const cached = readPageCache('team_companies');
const [profiles, setProfiles] = useState([]); const [companies, setCompanies] = useState(() => cached?.companies || []);
const [projects, setProjects] = useState([]); const [profiles, setProfiles] = useState(() => cached?.profiles || []);
const [tasks, setTasks] = useState([]); const [companyMemberships, setCompanyMemberships] = useState(() => cached?.companyMemberships || []);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(() => !cached);
const [showNew, setShowNew] = useState(false); const [showNew, setShowNew] = useState(false);
const [newForm, setNewForm] = useState({ name: '', phone: '', address: '' }); const [newForm, setNewForm] = useState({ name: '', phone: '', address: '' });
const [showNewUser, setShowNewUser] = useState(false); const [showNewUser, setShowNewUser] = useState(false);
@@ -20,25 +30,29 @@ export default function Companies() {
const [editingUserId, setEditingUserId] = useState(null); const [editingUserId, setEditingUserId] = useState(null);
const [editUserVal, setEditUserVal] = useState(''); const [editUserVal, setEditUserVal] = useState('');
const [deletingUserId, setDeletingUserId] = useState(null); const [deletingUserId, setDeletingUserId] = useState(null);
const [restoringArchive, setRestoringArchive] = useState(false);
const [restoreStatus, setRestoreStatus] = useState('');
const [filterCompany, setFilterCompany] = useState('');
const [activeTab, setActiveTab] = useState('companies');
const restoreInputRef = useRef(null);
async function load() {
const [{ data: co }, { data: prof }, { data: memberships }] = await Promise.all([
supabase.from('companies').select('*').order('name'),
supabase.from('profiles').select('id, name, email, company_id, role').in('role', ['client', 'external']).order('name'),
supabase.from('company_members').select('company_id, profile_id'),
]);
setCompanies(co || []);
setProfiles(prof || []);
setCompanyMemberships(memberships || []);
writePageCache('team_companies', { companies: co || [], profiles: prof || [], companyMemberships: memberships || [] });
setLoading(false);
}
useEffect(() => { useEffect(() => {
load(); load();
}, []); }, []);
async function load() {
const [{ data: co }, { data: prof }, { data: p }, { data: t }] = await Promise.all([
supabase.from('companies').select('*').order('name'),
supabase.from('profiles').select('id, name, email, company_id').eq('role', 'client'),
supabase.from('projects').select('id, company_id, status'),
supabase.from('tasks').select('id, project_id, status'),
]);
setCompanies(co || []);
setProfiles(prof || []);
setProjects(p || []);
setTasks(t || []);
setLoading(false);
}
const handleCreate = async (e) => { const handleCreate = async (e) => {
e.preventDefault(); e.preventDefault();
if (!newForm.name.trim()) return; if (!newForm.name.trim()) return;
@@ -50,6 +64,7 @@ export default function Companies() {
}).select().single(); }).select().single();
setSaving(false); setSaving(false);
if (data) { if (data) {
syncSeafileFolders().catch((error) => console.warn('Seafile folder sync failed:', error.message));
setShowNew(false); setShowNew(false);
setNewForm({ name: '', phone: '', address: '' }); setNewForm({ name: '', phone: '', address: '' });
navigate(`/companies/${data.id}`); navigate(`/companies/${data.id}`);
@@ -58,16 +73,39 @@ export default function Companies() {
const handleDeleteCompany = async (company) => { const handleDeleteCompany = async (company) => {
if (!window.confirm(`Delete "${company.name}"? This will permanently delete all projects, jobs, files, and data for this company. This cannot be undone.`)) return; if (!window.confirm(`Delete "${company.name}"? This will permanently delete all projects, jobs, files, and data for this company. This cannot be undone.`)) return;
await deleteCompanyData(company.id);
const companyProjects = projects.filter(p => p.company_id === company.id);
const projectIds = companyProjects.map(p => p.id);
if (projectIds.length) {
const companyTaskIds = tasks.filter(t => projectIds.includes(t.project_id)).map(t => t.id);
await cleanupTaskStorage(companyTaskIds);
}
await supabase.from('companies').delete().eq('id', company.id);
setCompanies(prev => prev.filter(c => c.id !== company.id)); setCompanies(prev => prev.filter(c => c.id !== company.id));
load();
};
const handleRestoreArchive = async (e) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
setRestoringArchive(true);
try {
const result = await restoreCompanyArchive(file, { onProgress: setRestoreStatus });
await load();
const missing = [];
if (result.missingProfiles.companyProfiles) missing.push(`${result.missingProfiles.companyProfiles} client profiles were not re-linked`);
if (result.missingProfiles.projectMembers) missing.push(`${result.missingProfiles.projectMembers} external project memberships were skipped`);
if (result.missingProfiles.taskAssignments) missing.push(`${result.missingProfiles.taskAssignments} task assignments were cleared`);
if (result.missingProfiles.submissions) missing.push(`${result.missingProfiles.submissions} submission user links were cleared`);
if (result.missingProfiles.invoices) missing.push(`${result.missingProfiles.invoices} invoice creator links were cleared`);
alert(
missing.length
? `Restored ${result.companyCount || 1} compan${(result.companyCount || 1) === 1 ? 'y' : 'ies'}.\n\nNote: ${missing.join('; ')}.`
: `Restored ${result.companyCount || 1} compan${(result.companyCount || 1) === 1 ? 'y' : 'ies'} successfully.`
);
} catch (error) {
alert(`Restore failed: ${error.message}`);
} finally {
setRestoringArchive(false);
setRestoreStatus('');
}
}; };
const handleEditUserSave = async (userId) => { const handleEditUserSave = async (userId) => {
@@ -81,7 +119,8 @@ export default function Companies() {
if (!window.confirm(`Delete "${user.name}"? This will permanently remove their account. This cannot be undone.`)) return; if (!window.confirm(`Delete "${user.name}"? This will permanently remove their account. This cannot be undone.`)) return;
setDeletingUserId(user.id); setDeletingUserId(user.id);
const { data, error } = await supabase.functions.invoke('delete-user', { body: { user_id: user.id } }); const { data, error } = await supabase.functions.invoke('delete-user', { body: { user_id: user.id } });
const errMsg = data?.error || error?.message; const errBody = error?.context ? await error.context.json().catch(() => null) : null;
const errMsg = errBody?.error || data?.error || error?.message;
if (errMsg) { alert(`Failed to delete user: ${errMsg}`); setDeletingUserId(null); return; } if (errMsg) { alert(`Failed to delete user: ${errMsg}`); setDeletingUserId(null); return; }
setProfiles(prev => prev.filter(u => u.id !== user.id)); setProfiles(prev => prev.filter(u => u.id !== user.id));
setDeletingUserId(null); setDeletingUserId(null);
@@ -101,19 +140,33 @@ export default function Companies() {
}, },
}); });
setSaving(false); setSaving(false);
const errMsg = data?.error || error?.message || (error ? JSON.stringify(error) : null); const errBody = error?.context ? await error.context.json().catch(() => null) : null;
const errMsg = errBody?.error || data?.error || error?.message;
if (errMsg) { if (errMsg) {
setUserError(errMsg); setUserError(errMsg);
return; return;
} }
setShowNewUser(false); setShowNewUser(false);
setUserForm({ name: '', email: '', password: '', company_id: '', role: 'client' }); setUserForm({ name: '', email: '', password: '', company_id: '', role: 'client' });
syncSeafileFolders().catch((syncError) => console.warn('Seafile folder sync failed:', syncError.message));
load(); load();
}; };
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>; if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
const unassigned = profiles.filter(p => !p.company_id); const getProfileCompanyIds = (profile) => {
const ids = new Set(
companyMemberships
.filter(membership => membership.profile_id === profile.id && profile.role === 'client')
.map(membership => membership.company_id)
);
if (profile.role === 'client' && profile.company_id) ids.add(profile.company_id);
return [...ids];
};
const clientProfiles = profiles.filter(profile => profile.role === 'client');
const subcontractors = profiles.filter(profile => profile.role === 'external');
const unassigned = clientProfiles.filter(profile => getProfileCompanyIds(profile).length === 0);
return ( return (
<Layout> <Layout>
@@ -122,20 +175,48 @@ export default function Companies() {
<div className="page-title">Clients & Users</div> <div className="page-title">Clients & Users</div>
<div className="page-subtitle"> <div className="page-subtitle">
{companies.length} {companies.length !== 1 ? 'companies' : 'company'} {companies.length} {companies.length !== 1 ? 'companies' : 'company'}
<span style={{ marginLeft: 10 }}>
· {clientProfiles.length} client user{clientProfiles.length !== 1 ? 's' : ''}
</span>
<span style={{ marginLeft: 10 }}>
· {subcontractors.length} subcontractor{subcontractors.length !== 1 ? 's' : ''}
</span>
{unassigned.length > 0 && ( {unassigned.length > 0 && (
<span style={{ marginLeft: 10, color: 'var(--danger)', fontWeight: 600 }}> <span style={{ marginLeft: 10, color: 'var(--danger)', fontWeight: 600 }}>
· {unassigned.length} unassigned user{unassigned.length !== 1 ? 's' : ''} · {unassigned.length} unassigned client{unassigned.length !== 1 ? 's' : ''}
</span> </span>
)} )}
</div> </div>
</div> </div>
<div style={{ display: 'flex', gap: 8 }}> <input
<button className="btn btn-outline btn-sm" onClick={() => { setShowNewUser(s => !s); setShowNew(false); setUserError(''); }}> ref={restoreInputRef}
{showNewUser ? 'Cancel' : '+ New User'} type="file"
</button> accept=".zip,application/zip"
<button className="btn btn-primary btn-sm" onClick={() => { setShowNew(s => !s); setShowNewUser(false); }}> style={{ display: 'none' }}
{showNew ? 'Cancel' : '+ New Company'} onChange={handleRestoreArchive}
</button> />
</div>
<div className="stats-grid" style={{ marginBottom: 24 }}>
<div className="stat-card">
<div className="stat-icon">🏢</div>
<div className="stat-value">{companies.length}</div>
<div className="stat-label">Companies</div>
</div>
<div className="stat-card">
<div className="stat-icon">👥</div>
<div className="stat-value">{clientProfiles.length}</div>
<div className="stat-label">Client Users</div>
</div>
<div className="stat-card">
<div className="stat-icon">🧾</div>
<div className="stat-value">{subcontractors.length}</div>
<div className="stat-label">Subcontractors</div>
</div>
<div className="stat-card">
<div className="stat-icon"></div>
<div className="stat-value">{unassigned.length}</div>
<div className="stat-label">Unassigned Clients</div>
</div> </div>
</div> </div>
@@ -166,7 +247,7 @@ export default function Companies() {
<select value={userForm.role} onChange={e => setUserForm(f => ({ ...f, role: e.target.value, company_id: '' }))}> <select value={userForm.role} onChange={e => setUserForm(f => ({ ...f, role: e.target.value, company_id: '' }))}>
<option value="client">Client</option> <option value="client">Client</option>
<option value="team">Team</option> <option value="team">Team</option>
<option value="external">External</option> <option value="external">Subcontractor</option>
</select> </select>
</div> </div>
</div> </div>
@@ -235,121 +316,362 @@ export default function Companies() {
</div> </div>
)} )}
{unassigned.length > 0 && ( <div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div className="card" style={{ marginBottom: 24, borderColor: 'var(--danger)' }}> <div className="card-title" style={{ marginBottom: 0, display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<div className="card-title" style={{ color: 'var(--danger)' }}>Unassigned Users</div> {[
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 12 }}> { id: 'companies', label: 'Companies' },
These users have signed up but haven't been assigned to a company yet. { id: 'clients', label: 'Clients' },
</p> { id: 'subcontractors', label: 'Subcontractors' },
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> ].map((tab, index) => (
{unassigned.map(user => ( <span key={tab.id} style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
<div key={user.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)' }}> {index > 0 && <span style={{ color: 'var(--text-muted)' }}>|</span>}
<div style={{ flex: 1 }}> <button
{editingUserId === user.id ? ( type="button"
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> onClick={() => setActiveTab(tab.id)}
<input style={{
type="text" background: 'none',
value={editUserVal} border: 'none',
onChange={e => setEditUserVal(e.target.value)} padding: 0,
autoFocus margin: 0,
style={{ margin: 0, fontSize: 13, padding: '3px 8px', width: 180 }} cursor: 'pointer',
onKeyDown={e => { if (e.key === 'Enter') handleEditUserSave(user.id); if (e.key === 'Escape') setEditingUserId(null); }} color: activeTab === tab.id ? 'var(--text-primary)' : 'var(--text-muted)',
/> font: 'inherit',
<button className="btn btn-primary btn-sm" onClick={() => handleEditUserSave(user.id)}>Save</button> textTransform: 'inherit',
<button className="btn btn-outline btn-sm" onClick={() => setEditingUserId(null)}>Cancel</button> letterSpacing: 'inherit',
</div> }}
) : ( >
<> {tab.label}
<div style={{ fontWeight: 600, fontSize: 13 }}>{user.name}</div> </button>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{user.email}</div> </span>
</> ))}
)}
</div>
{editingUserId !== user.id && (
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<span style={{ fontSize: 12, color: 'var(--danger)', fontWeight: 500, marginRight: 4 }}>No company</span>
<button className="btn btn-outline btn-sm" onClick={() => { setEditingUserId(user.id); setEditUserVal(user.name); }}>Edit</button>
<button
className="btn btn-outline btn-sm"
style={{ color: 'var(--danger)', borderColor: 'var(--danger)' }}
onClick={() => handleDeleteUser(user)}
disabled={deletingUserId === user.id}
>{deletingUserId === user.id ? '...' : 'Delete'}</button>
</div>
)}
</div>
))}
</div>
</div> </div>
{activeTab === 'companies' && (
<button
className="btn btn-primary btn-sm"
onClick={() => { setShowNew(true); setShowNewUser(false); }}
>
+ New Company
</button>
)}
{activeTab === 'clients' && (
<button
className="btn btn-primary btn-sm"
onClick={() => { setShowNewUser(true); setShowNew(false); setUserForm(f => ({ ...f, role: 'client' })); }}
>
+ New Client
</button>
)}
{activeTab === 'subcontractors' && (
<button
className="btn btn-primary btn-sm"
onClick={() => { setShowNewUser(true); setShowNew(false); setUserForm(f => ({ ...f, role: 'external', company_id: '' })); }}
>
+ New Subcontractor
</button>
)}
</div>
{activeTab === 'companies' && (
<>
<div className="card request-toolbar-card">
<div className="request-toolbar-section">
<div className="card-title" style={{ marginBottom: 10 }}>Restore Archive</div>
<div className="request-toolbar-actions">
<button
className="btn btn-outline btn-sm"
onClick={() => restoreInputRef.current?.click()}
disabled={restoringArchive}
>
{restoringArchive ? 'Restoring...' : 'Restore Archive'}
</button>
</div>
{restoreStatus && <div className="request-toolbar-status">{restoreStatus}</div>}
</div>
</div>
{companies.length === 0 ? (
<div className="empty-state">
<h3>No companies yet</h3>
<p>Create a company to get started.</p>
<button className="btn btn-primary" style={{ marginTop: 16 }} onClick={() => setShowNew(true)}>+ New Company</button>
</div>
) : (
<div className="table-wrapper">
<table>
<thead>
<tr>
<th>Company</th>
<th>Clients</th>
<th>Phone</th>
<th>Address</th>
<th style={{ width: 1, whiteSpace: 'nowrap' }}>Actions</th>
</tr>
</thead>
<tbody>
{companies.map(company => {
const companyProfiles = clientProfiles.filter(profile => getProfileCompanyIds(profile).includes(company.id));
return (
<tr key={company.id} onClick={() => navigate(`/companies/${company.id}`)} style={{ cursor: 'pointer' }}>
<td>
<div style={{ fontWeight: 600 }}>{company.name}</div>
{companyProfiles.length > 0 && (
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 6 }}>
{companyProfiles.map(profile => (
<div key={profile.id} style={{ display: 'flex', alignItems: 'flex-start', gap: 8, color: 'var(--text-muted)', fontSize: 12, lineHeight: 1.4 }}>
<span style={{ color: 'var(--accent)', lineHeight: 1.2 }}></span>
<div style={{ minWidth: 0 }}>
<div style={{ color: 'var(--text-secondary)', fontWeight: 600 }}>{profile.name || '—'}</div>
</div>
</div>
))}
</div>
)}
</td>
<td>{companyProfiles.length}</td>
<td>{company.phone || '—'}</td>
<td>{company.address || '—'}</td>
<td onClick={(e) => e.stopPropagation()}>
<button
className="btn btn-outline btn-sm"
style={{ color: 'var(--danger)', borderColor: 'var(--danger)' }}
onClick={() => handleDeleteCompany(company)}
>
Delete
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</>
)} )}
{companies.length === 0 ? ( {activeTab === 'clients' && (
<div className="empty-state"> <>
<h3>No companies yet</h3> <div className="card request-toolbar-card" style={{ marginBottom: 16 }}>
<p>Create a company to get started.</p> {companies.length > 0 && (
<button className="btn btn-primary" style={{ marginTop: 16 }} onClick={() => setShowNew(true)}>+ New Company</button> <div className="request-toolbar-grid">
</div> <div className="request-toolbar-section">
) : ( <div className="card-title" style={{ marginBottom: 10 }}>Filter By Company</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <div className="request-filter-row">
{companies.map(company => {
const companyProfiles = profiles.filter(p => p.company_id === company.id);
const companyProjects = projects.filter(p => p.company_id === company.id);
const projectIds = companyProjects.map(p => p.id);
const activeTasks = tasks.filter(t => projectIds.includes(t.project_id) && t.status !== 'client_approved');
return (
<div key={company.id} style={{ border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden', background: 'var(--card-bg)' }}>
<Link to={`/companies/${company.id}`} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 18px', background: 'var(--card-bg-2)', borderBottom: '1px solid var(--border)', textDecoration: 'none', cursor: 'pointer' }}>
<div>
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--text-primary)' }}>{company.name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 3 }}>
{companyProfiles.length} user{companyProfiles.length !== 1 ? 's' : ''}
{' · '}
{companyProjects.length} project{companyProjects.length !== 1 ? 's' : ''}
{activeTasks.length > 0 && <> · <span style={{ color: 'var(--accent)', fontWeight: 600 }}>{activeTasks.length} active</span></>}
{company.phone && <> · {company.phone}</>}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}></span>
<button <button
type="button" className={`btn btn-sm ${!filterCompany ? 'btn-primary' : 'btn-outline'}`}
onClick={e => { e.preventDefault(); e.stopPropagation(); handleDeleteCompany(company); }} onClick={() => setFilterCompany('')}
style={{ background: 'none', border: 'none', color: 'var(--danger, #dc2626)', cursor: 'pointer', fontSize: 16, padding: '0 4px', lineHeight: 1 }} >
title="Delete company" All
>✕</button> </button>
</div> {companies.map(company => (
</Link> <button
key={company.id}
{companyProfiles.length > 0 && ( className={`btn btn-sm ${filterCompany === company.id ? 'btn-primary' : 'btn-outline'}`}
<div> onClick={() => setFilterCompany(current => current === company.id ? '' : company.id)}
{companyProfiles.map((profile, i) => (
<div
key={profile.id}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '10px 18px',
borderBottom: i < companyProfiles.length - 1 ? '1px solid var(--border)' : 'none',
}}
> >
<div style={{ {company.name}
width: 28, height: 28, borderRadius: 4, background: 'var(--accent)', </button>
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 11, fontWeight: 700, color: '#111', flexShrink: 0,
}}>
{profile.name?.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
</div>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{profile.name}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{profile.email || ''}</div>
</div>
</div>
))} ))}
</div> </div>
)} </div>
</div> </div>
); )}
})} </div>
{unassigned.length > 0 && (
<div className="card" style={{ marginBottom: 24, borderColor: 'var(--danger)' }}>
<div className="card-title" style={{ color: 'var(--danger)' }}>Unassigned Client Users</div>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 12 }}>
These client users are not linked to any company yet.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{unassigned.map(user => (
<div key={user.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
<div style={{ flex: 1 }}>
{editingUserId === user.id ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<input
type="text"
value={editUserVal}
onChange={e => setEditUserVal(e.target.value)}
autoFocus
style={{ margin: 0, fontSize: 13, padding: '3px 8px', width: 180 }}
onKeyDown={e => { if (e.key === 'Enter') handleEditUserSave(user.id); if (e.key === 'Escape') setEditingUserId(null); }}
/>
<button className="btn btn-primary btn-sm" onClick={() => handleEditUserSave(user.id)}>Save</button>
<button className="btn btn-outline btn-sm" onClick={() => setEditingUserId(null)}>Cancel</button>
</div>
) : (
<>
<div style={{ fontWeight: 600, fontSize: 13 }}>{user.name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{user.email}</div>
</>
)}
</div>
{editingUserId !== user.id && (
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<span style={{ fontSize: 12, color: 'var(--danger)', fontWeight: 500, marginRight: 4 }}>No company</span>
<button className="btn btn-outline btn-sm" onClick={() => { setEditingUserId(user.id); setEditUserVal(user.name); }}>Edit</button>
<button
className="btn btn-outline btn-sm"
style={{ color: 'var(--danger)', borderColor: 'var(--danger)' }}
onClick={() => handleDeleteUser(user)}
disabled={deletingUserId === user.id}
>{deletingUserId === user.id ? '...' : 'Delete'}</button>
</div>
)}
</div>
))}
</div>
</div>
)}
{clientProfiles.filter(profile => !filterCompany || getProfileCompanyIds(profile).includes(filterCompany)).length === 0 ? (
<div className="empty-state">
<h3>No client users</h3>
<p>Create a client user to link them to a company.</p>
</div>
) : (
<div className="table-wrapper">
<table>
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Company</th>
<th>Role</th>
<th style={{ width: 1, whiteSpace: 'nowrap' }}>Actions</th>
</tr>
</thead>
<tbody>
{clientProfiles
.filter(profile => !filterCompany || getProfileCompanyIds(profile).includes(filterCompany))
.map(user => {
const companyNames = getProfileCompanyIds(user)
.map(companyId => companies.find(company => company.id === companyId)?.name)
.filter(Boolean);
return (
<tr key={user.id}>
<td style={{ fontWeight: 600 }}>
{editingUserId === user.id ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<input
type="text"
value={editUserVal}
onChange={e => setEditUserVal(e.target.value)}
autoFocus
style={{ margin: 0, fontSize: 13, padding: '3px 8px', width: 180 }}
onKeyDown={e => {
if (e.key === 'Enter') handleEditUserSave(user.id);
if (e.key === 'Escape') setEditingUserId(null);
}}
/>
<button className="btn btn-primary btn-sm" onClick={() => handleEditUserSave(user.id)}>Save</button>
<button className="btn btn-outline btn-sm" onClick={() => setEditingUserId(null)}>Cancel</button>
</div>
) : (
user.name || '—'
)}
</td>
<td>{user.email || '—'}</td>
<td>{companyNames.length ? companyNames.join(', ') : '—'}</td>
<td>{getRoleLabel(user.role)}</td>
<td>
{editingUserId !== user.id && (
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end', flexWrap: 'nowrap', whiteSpace: 'nowrap' }}>
<button className="btn btn-outline btn-sm" onClick={() => { setEditingUserId(user.id); setEditUserVal(user.name || ''); }}>
Edit
</button>
<button
className="btn btn-outline btn-sm"
style={{ color: 'var(--danger)', borderColor: 'var(--danger)' }}
onClick={() => handleDeleteUser(user)}
disabled={deletingUserId === user.id}
>
{deletingUserId === user.id ? '...' : 'Delete'}
</button>
</div>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</>
)}
{activeTab === 'subcontractors' && (
<div>
{subcontractors.length === 0 ? (
<div className="empty-state" style={{ padding: '20px 18px' }}>
<h3>No subcontractors yet</h3>
<p>Create a subcontractor user to manage external access and POs.</p>
</div>
) : (
<div className="table-wrapper">
<table>
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Role</th>
<th style={{ width: 1, whiteSpace: 'nowrap' }}>Actions</th>
</tr>
</thead>
<tbody>
{subcontractors.map(user => (
<tr key={user.id}>
<td style={{ fontWeight: 600 }}>
{editingUserId === user.id ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<input
type="text"
value={editUserVal}
onChange={e => setEditUserVal(e.target.value)}
autoFocus
style={{ margin: 0, fontSize: 13, padding: '3px 8px', width: 180 }}
onKeyDown={e => {
if (e.key === 'Enter') handleEditUserSave(user.id);
if (e.key === 'Escape') setEditingUserId(null);
}}
/>
<button className="btn btn-primary btn-sm" onClick={() => handleEditUserSave(user.id)}>Save</button>
<button className="btn btn-outline btn-sm" onClick={() => setEditingUserId(null)}>Cancel</button>
</div>
) : (
user.name || '—'
)}
</td>
<td>{user.email || '—'}</td>
<td>{getRoleLabel(user.role)}</td>
<td>
{editingUserId !== user.id && (
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end', flexWrap: 'nowrap', whiteSpace: 'nowrap' }}>
<button className="btn btn-outline btn-sm" onClick={() => { setEditingUserId(user.id); setEditUserVal(user.name || ''); }}>
Edit
</button>
<button
className="btn btn-outline btn-sm"
style={{ color: 'var(--danger)', borderColor: 'var(--danger)' }}
onClick={() => handleDeleteUser(user)}
disabled={deletingUserId === user.id}
>
{deletingUserId === user.id ? '...' : 'Delete'}
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div> </div>
)} )}
</Layout> </Layout>
+66 -30
View File
@@ -4,7 +4,8 @@ import Layout from '../../components/Layout';
import StatusBadge from '../../components/StatusBadge'; import StatusBadge from '../../components/StatusBadge';
import { supabase } from '../../lib/supabase'; import { supabase } from '../../lib/supabase';
import { serviceTypes } from '../../data/mockData'; import { serviceTypes } from '../../data/mockData';
import { cleanupTaskStorage } from '../../lib/deleteHelpers'; import { cleanupTaskStorage, deleteCompanyData } from '../../lib/deleteHelpers';
import { syncSeafileFolders } from '../../lib/seafileFolders';
export default function CompanyDetail() { export default function CompanyDetail() {
const { id } = useParams(); const { id } = useParams();
@@ -14,7 +15,7 @@ export default function CompanyDetail() {
const [projects, setProjects] = useState([]); const [projects, setProjects] = useState([]);
const [tasks, setTasks] = useState([]); const [tasks, setTasks] = useState([]);
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [unassigned, setUnassigned] = useState([]); const [availableUsers, setAvailableUsers] = useState([]);
const [prices, setPrices] = useState([]); const [prices, setPrices] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [tab, setTab] = useState('users'); const [tab, setTab] = useState('users');
@@ -30,28 +31,38 @@ export default function CompanyDetail() {
const [editUserVal, setEditUserVal] = useState(''); const [editUserVal, setEditUserVal] = useState('');
const [deletingUserId, setDeletingUserId] = useState(null); const [deletingUserId, setDeletingUserId] = useState(null);
useEffect(() => {
load();
}, [id]);
async function load() { async function load() {
const [{ data: co }, { data: p }, { data: pr }, { data: u }, { data: unassignedUsers }, { data: t }] = await Promise.all([ const [{ data: co }, { data: p }, { data: pr }, { data: memberRows }, { data: allUsers }, { data: t }] = await Promise.all([
supabase.from('companies').select('*').eq('id', id).single(), supabase.from('companies').select('*').eq('id', id).single(),
supabase.from('projects').select('*').eq('company_id', id).order('created_at', { ascending: false }), supabase.from('projects').select('*').eq('company_id', id).order('created_at', { ascending: false }),
supabase.from('company_prices').select('*').eq('company_id', id), supabase.from('company_prices').select('*').eq('company_id', id),
supabase.from('profiles').select('id, name, email, created_at').eq('company_id', id).eq('role', 'client'), supabase.from('company_members').select('profile_id, created_at, profile:profiles(id, name, email, created_at, role, company_id)').eq('company_id', id),
supabase.from('profiles').select('id, name, email').eq('role', 'client').is('company_id', null), supabase.from('profiles').select('id, name, email, created_at, role, company_id').eq('role', 'client').order('name'),
supabase.from('tasks').select('*, project:projects!inner(company_id)').eq('project.company_id', id), supabase.from('tasks').select('*, project:projects!inner(company_id)').eq('project.company_id', id),
]); ]);
const assignedMap = new Map();
(memberRows || []).forEach(row => {
if (row.profile?.role === 'client') assignedMap.set(row.profile.id, { ...row.profile, membership_created_at: row.created_at });
});
(allUsers || []).filter(user => user.company_id === id).forEach(user => {
if (!assignedMap.has(user.id)) assignedMap.set(user.id, user);
});
const assignedUsers = [...assignedMap.values()].sort((a, b) => (a.name || '').localeCompare(b.name || ''));
const assignedIds = new Set(assignedUsers.map(user => user.id));
setCompany(co); setCompany(co);
setProjects(p || []); setProjects(p || []);
setPrices(pr || []); setPrices(pr || []);
setUsers(u || []); setUsers(assignedUsers);
setUnassigned(unassignedUsers || []); setAvailableUsers((allUsers || []).filter(user => !assignedIds.has(user.id)));
setTasks(t || []); setTasks(t || []);
setLoading(false); setLoading(false);
} }
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
load();
}, [id]); // eslint-disable-line react-hooks/exhaustive-deps
const handleCompanyNameSave = async (e) => { const handleCompanyNameSave = async (e) => {
e.preventDefault(); e.preventDefault();
if (!nameVal.trim()) return; if (!nameVal.trim()) return;
@@ -73,38 +84,61 @@ export default function CompanyDetail() {
if (!window.confirm(`Delete "${user.name}"? This will permanently remove their account and all access. This cannot be undone.`)) return; if (!window.confirm(`Delete "${user.name}"? This will permanently remove their account and all access. This cannot be undone.`)) return;
setDeletingUserId(user.id); setDeletingUserId(user.id);
const { data, error } = await supabase.functions.invoke('delete-user', { body: { user_id: user.id } }); const { data, error } = await supabase.functions.invoke('delete-user', { body: { user_id: user.id } });
const errMsg = data?.error || error?.message; const errBody = error?.context ? await error.context.json().catch(() => null) : null;
const errMsg = errBody?.error || data?.error || error?.message;
if (errMsg) { alert(`Failed to delete user: ${errMsg}`); setDeletingUserId(null); return; } if (errMsg) { alert(`Failed to delete user: ${errMsg}`); setDeletingUserId(null); return; }
setUsers(prev => prev.filter(u => u.id !== user.id)); setUsers(prev => prev.filter(u => u.id !== user.id));
setAvailableUsers(prev => prev.filter(u => u.id !== user.id));
setDeletingUserId(null); setDeletingUserId(null);
}; };
const handleAssignUser = async (userId) => { const handleAssignUser = async (userId) => {
setAssigning(true); setAssigning(true);
await supabase.from('profiles').update({ company_id: id }).eq('id', userId); const user = availableUsers.find(u => u.id === userId);
// Move user from unassigned to users list const { error } = await supabase
const user = unassigned.find(u => u.id === userId); .from('company_members')
.upsert({ company_id: id, profile_id: userId }, { onConflict: 'company_id,profile_id' });
if (error) {
alert('Failed to assign user. Please try again.');
setAssigning(false);
return;
}
if (user && !user.company_id) {
await supabase.from('profiles').update({ company_id: id }).eq('id', userId);
}
if (user) { if (user) {
setUsers(prev => [...prev, { ...user, created_at: new Date().toISOString() }]); setUsers(prev => [...prev, { ...user, company_id: user.company_id || id, created_at: user.created_at || new Date().toISOString() }]
setUnassigned(prev => prev.filter(u => u.id !== userId)); .sort((a, b) => (a.name || '').localeCompare(b.name || '')));
setAvailableUsers(prev => prev.filter(u => u.id !== userId));
syncSeafileFolders().catch((syncError) => console.warn('Seafile folder sync failed:', syncError.message));
} }
setAssigning(false); setAssigning(false);
}; };
const handleRemoveUser = async (userId) => { const handleRemoveUser = async (userId) => {
if (!window.confirm('Remove this user from the company? They will lose access to all company data.')) return; if (!window.confirm('Remove this user from the company? They will lose access to this company data.')) return;
await supabase.from('profiles').update({ company_id: null }).eq('id', userId); await supabase.from('company_members').delete().eq('company_id', id).eq('profile_id', userId);
const user = users.find(u => u.id === userId); const user = users.find(u => u.id === userId);
if (user?.company_id === id) {
const { data: nextMembership } = await supabase
.from('company_members')
.select('company_id')
.eq('profile_id', userId)
.neq('company_id', id)
.limit(1)
.maybeSingle();
await supabase.from('profiles').update({ company_id: nextMembership?.company_id || null }).eq('id', userId);
}
if (user) { if (user) {
setUsers(prev => prev.filter(u => u.id !== userId)); setUsers(prev => prev.filter(u => u.id !== userId));
setUnassigned(prev => [...prev, user]); setAvailableUsers(prev => [...prev, { ...user, company_id: user.company_id === id ? null : user.company_id }]
.sort((a, b) => (a.name || '').localeCompare(b.name || '')));
} }
}; };
const handleDeleteCompany = async () => { const handleDeleteCompany = async () => {
if (!window.confirm(`Delete "${company.name}"? This will permanently delete all projects, jobs, files, and data. This cannot be undone.`)) return; if (!window.confirm(`Delete "${company.name}"? This will permanently delete all projects, jobs, files, and data. This cannot be undone.`)) return;
await cleanupTaskStorage(tasks.map(t => t.id)); await deleteCompanyData(id);
await supabase.from('companies').delete().eq('id', id);
navigate('/companies'); navigate('/companies');
}; };
@@ -247,9 +281,9 @@ export default function CompanyDetail() {
}} }}
> >
{t} {t}
{t === 'users' && unassigned.length > 0 && ( {t === 'users' && availableUsers.length > 0 && (
<span style={{ marginLeft: 6, fontSize: 10, background: 'var(--danger)', color: 'white', padding: '1px 5px', borderRadius: 10, fontWeight: 700 }}> <span style={{ marginLeft: 6, fontSize: 10, background: 'var(--danger)', color: 'white', padding: '1px 5px', borderRadius: 10, fontWeight: 700 }}>
{unassigned.length} {availableUsers.length}
</span> </span>
)} )}
</button> </button>
@@ -297,6 +331,7 @@ export default function CompanyDetail() {
<> <>
<div style={{ fontWeight: 600, fontSize: 14 }}>{user.name}</div> <div style={{ fontWeight: 600, fontSize: 14 }}>{user.name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{user.email || '—'}</div> <div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{user.email || '—'}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', textTransform: 'capitalize', marginTop: 2 }}>{user.role || '—'}</div>
</> </>
)} )}
</div> </div>
@@ -327,14 +362,14 @@ export default function CompanyDetail() {
)} )}
</div> </div>
{unassigned.length > 0 && ( {availableUsers.length > 0 && (
<div className="card"> <div className="card">
<div className="card-title">Unassigned Users</div> <div className="card-title">Available Users</div>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 14 }}> <p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 14 }}>
These users have signed up but aren't assigned to any company yet. Add an existing client user to this company. External subcontractors are assigned to projects instead.
</p> </p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{unassigned.map(user => ( {availableUsers.map(user => (
<div key={user.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)' }}> <div key={user.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
{editingUserId === user.id ? ( {editingUserId === user.id ? (
@@ -354,6 +389,7 @@ export default function CompanyDetail() {
<> <>
<div style={{ fontWeight: 600, fontSize: 13 }}>{user.name}</div> <div style={{ fontWeight: 600, fontSize: 13 }}>{user.name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{user.email}</div> <div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{user.email}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', textTransform: 'capitalize', marginTop: 2 }}>{user.role || '—'}</div>
</> </>
)} )}
</div> </div>
@@ -422,8 +458,8 @@ export default function CompanyDetail() {
const active = projectTasks.filter(t => t.status !== 'client_approved').length; const active = projectTasks.filter(t => t.status !== 'client_approved').length;
const done = projectTasks.filter(t => t.status === 'client_approved').length; const done = projectTasks.filter(t => t.status === 'client_approved').length;
return ( return (
<div key={project.id} style={{ display: 'flex', alignItems: 'center', background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden' }}> <div key={project.id} className="interactive-surface" style={{ display: 'flex', alignItems: 'center', background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden' }}>
<Link to={`/projects/${project.id}`} style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px', textDecoration: 'none', cursor: 'pointer' }}> <Link to={`/projects/${project.id}`} className="interactive-row" style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px', textDecoration: 'none', cursor: 'pointer' }}>
<div> <div>
<div style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)' }}>{project.name}</div> <div style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)' }}>{project.name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 3 }}> <div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 3 }}>
+432
View File
@@ -0,0 +1,432 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import JSZip from 'jszip';
import { heicTo, isHeic } from 'heic-to/csp';
import Layout from '../../components/Layout';
import LoadingButton from '../../components/LoadingButton';
const INPUT_ACCEPT = 'image/*,.heic,.heif,.avif,.tif,.tiff,.bmp,.webp,.jpeg,.jpg,.png,.gif';
const OUTPUT_FORMATS = [
{ value: 'jpeg', label: 'JPG', mime: 'image/jpeg', extension: 'jpg' },
{ value: 'png', label: 'PNG', mime: 'image/png', extension: 'png' },
{ value: 'webp', label: 'WEBP', mime: 'image/webp', extension: 'webp' },
];
const HEIC_EXTENSIONS = new Set(['heic', 'heif']);
const MAX_FILES = 100;
function getExtension(name = '') {
return name.split('.').pop()?.toLowerCase() || '';
}
function isHeicFile(file) {
return HEIC_EXTENSIONS.has(getExtension(file.name)) || file.type === 'image/heic' || file.type === 'image/heif';
}
function stripExtension(name = '') {
return name.replace(/\.[^.]+$/, '') || 'converted-image';
}
function downloadBlob(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);
}
async function loadImageFromBlob(blob) {
if ('createImageBitmap' in window) {
try {
return await createImageBitmap(blob);
} catch {
// Fallback to HTMLImageElement below.
}
}
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
resolve(img);
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('Could not decode image.'));
};
img.src = url;
});
}
async function canvasToBlob(canvas, type, quality) {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (!blob) {
reject(new Error('Conversion failed.'));
return;
}
resolve(blob);
}, type, quality);
});
}
async function convertRasterBlob(blob, format, quality) {
const image = await loadImageFromBlob(blob);
const width = image.width || image.naturalWidth;
const height = image.height || image.naturalHeight;
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas is not available in this browser.');
if (format.mime === 'image/jpeg') {
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, width, height);
}
ctx.drawImage(image, 0, 0, width, height);
const outputBlob = await canvasToBlob(canvas, format.mime, quality);
if (typeof image.close === 'function') image.close();
return outputBlob;
}
async function convertHeicFile(file, format, quality) {
if (format.mime === 'image/jpeg' || format.mime === 'image/png') {
try {
return await heicTo({ blob: file, type: format.mime, quality });
} catch (primaryError) {
try {
return await convertRasterBlob(file, format, quality);
} catch {
throw new Error(primaryError?.message || 'HEIC format not supported.');
}
}
}
const bitmap = await heicTo({ blob: file, type: 'bitmap' });
const width = bitmap.width || bitmap.naturalWidth;
const height = bitmap.height || bitmap.naturalHeight;
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas is not available in this browser.');
if (format.mime === 'image/jpeg') {
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, width, height);
}
ctx.drawImage(bitmap, 0, 0, width, height);
if (typeof bitmap.close === 'function') bitmap.close();
return canvasToBlob(canvas, format.mime, quality);
}
async function convertFile(file, format, quality) {
const looksHeic = isHeicFile(file);
const confirmedHeic = looksHeic ? await isHeic(file).catch(() => false) : false;
if (looksHeic || confirmedHeic) {
return convertHeicFile(file, format, quality);
}
return convertRasterBlob(file, format, quality);
}
export default function Converters() {
const inputRef = useRef(null);
const [files, setFiles] = useState([]);
const [outputFormat, setOutputFormat] = useState('jpeg');
const [quality, setQuality] = useState(0.92);
const [results, setResults] = useState([]);
const [converting, setConverting] = useState(false);
const [downloading, setDownloading] = useState('');
const [limitMessage, setLimitMessage] = useState('');
const selectedFormat = useMemo(
() => OUTPUT_FORMATS.find((format) => format.value === outputFormat) || OUTPUT_FORMATS[0],
[outputFormat]
);
useEffect(() => () => {
results.forEach((result) => {
if (result.previewUrl) URL.revokeObjectURL(result.previewUrl);
});
}, [results]);
const addFiles = (incoming) => {
const nextFiles = Array.from(incoming || []).filter(Boolean);
if (!nextFiles.length) return;
setFiles((current) => {
const existing = new Set(current.map(file => `${file.name}-${file.size}-${file.lastModified}`));
const deduped = nextFiles.filter(file => !existing.has(`${file.name}-${file.size}-${file.lastModified}`));
const availableSlots = Math.max(0, MAX_FILES - current.length);
const accepted = deduped.slice(0, availableSlots);
const skippedCount = deduped.length - accepted.length;
setLimitMessage(skippedCount > 0
? `Only the first ${MAX_FILES} files can be queued at one time. ${skippedCount} file${skippedCount === 1 ? '' : 's'} were skipped.`
: ''
);
return [...current, ...accepted];
});
};
const removeFile = (targetIndex) => {
setFiles((current) => current.filter((_, index) => index !== targetIndex));
};
const clearAll = () => {
setFiles([]);
setResults((current) => {
current.forEach((result) => {
if (result.previewUrl) URL.revokeObjectURL(result.previewUrl);
});
return [];
});
};
const handleConvert = async () => {
if (!files.length) return;
setConverting(true);
setResults((current) => {
current.forEach((result) => {
if (result.previewUrl) URL.revokeObjectURL(result.previewUrl);
});
return files.map((file) => ({
id: `${file.name}-${file.size}-${file.lastModified}`,
name: file.name,
status: 'pending',
previewUrl: '',
blob: null,
error: '',
}));
});
for (let index = 0; index < files.length; index += 1) {
const file = files[index];
const id = `${file.name}-${file.size}-${file.lastModified}`;
setResults((current) => current.map((result) => (
result.id === id ? { ...result, status: 'converting', error: '' } : result
)));
try {
const blob = await convertFile(file, selectedFormat, quality);
const previewUrl = URL.createObjectURL(blob);
setResults((current) => current.map((result) => {
if (result.id !== id) return result;
if (result.previewUrl) URL.revokeObjectURL(result.previewUrl);
return {
...result,
status: 'done',
blob,
previewUrl,
error: '',
};
}));
} catch (error) {
setResults((current) => current.map((result) => (
result.id === id
? { ...result, status: 'error', blob: null, previewUrl: '', error: error.message || 'Conversion failed.' }
: result
)));
}
}
setConverting(false);
};
const handleDownloadAll = async () => {
const completed = results.filter(result => result.status === 'done' && result.blob);
if (!completed.length || downloading) return;
setDownloading('all');
try {
const zip = new JSZip();
completed.forEach((result) => {
zip.file(`${stripExtension(result.name)}.${selectedFormat.extension}`, result.blob);
});
const blob = await zip.generateAsync({ type: 'blob' });
downloadBlob(blob, `converted-images-${selectedFormat.extension}.zip`);
} finally {
setDownloading('');
}
};
const completedCount = results.filter(result => result.status === 'done').length;
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">Image Converter</div>
<div className="page-subtitle">Convert image files in bulk, including HEIC/HEIF to JPG, PNG, or WEBP.</div>
</div>
</div>
<div style={{ display: 'grid', gap: 24 }}>
<div className="card">
<div className="card-title">Image Converter</div>
<div style={{ display: 'grid', gap: 18 }}>
<div
onClick={() => inputRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
addFiles(e.dataTransfer.files);
}}
style={{
border: '2px dashed var(--border)',
borderRadius: 10,
padding: '28px 18px',
textAlign: 'center',
cursor: 'pointer',
background: 'var(--card-bg-2)',
color: 'var(--text-muted)',
}}
>
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 6 }}>
Drop images here or click to upload
</div>
<div style={{ fontSize: 13 }}>
Supports JPG, PNG, WEBP, GIF, BMP, TIFF, AVIF, HEIC, and HEIF. Max {MAX_FILES} files per batch.
</div>
</div>
<input
ref={inputRef}
type="file"
accept={INPUT_ACCEPT}
multiple
style={{ display: 'none' }}
onChange={(e) => {
addFiles(e.target.files);
e.target.value = '';
}}
/>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 14 }}>
<div className="form-group" style={{ margin: 0 }}>
<label>Convert To</label>
<select value={outputFormat} onChange={(e) => setOutputFormat(e.target.value)}>
{OUTPUT_FORMATS.map((format) => (
<option key={format.value} value={format.value}>{format.label}</option>
))}
</select>
</div>
<div className="form-group" style={{ margin: 0 }}>
<label>Quality</label>
<input
type="range"
min="0.5"
max="1"
step="0.01"
value={quality}
disabled={selectedFormat.mime === 'image/png'}
onChange={(e) => setQuality(Number(e.target.value))}
/>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 6 }}>
{selectedFormat.mime === 'image/png'
? 'PNG uses lossless output.'
: `${Math.round(quality * 100)}% output quality.`}
</div>
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button className="btn btn-primary" onClick={handleConvert} disabled={!files.length || converting}>
{converting ? 'Converting...' : `Convert ${files.length ? `(${files.length})` : ''}`}
</button>
<LoadingButton className="btn btn-outline" loading={downloading === 'all'} disabled={!completedCount || Boolean(downloading)} loadingText="Preparing..." onClick={handleDownloadAll}>Download All</LoadingButton>
<button className="btn btn-outline" onClick={clearAll} disabled={!files.length && !results.length}>
Clear
</button>
</div>
{limitMessage && (
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{limitMessage}
</div>
)}
</div>
</div>
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 14, flexWrap: 'wrap' }}>
<div className="card-title" style={{ margin: 0 }}>Files</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{files.length} selected · {completedCount} converted
</div>
</div>
{!files.length ? (
<div style={{ color: 'var(--text-muted)', fontSize: 13 }}>No files added yet.</div>
) : (
<div style={{ display: 'grid', gap: 12 }}>
{files.map((file, index) => {
const id = `${file.name}-${file.size}-${file.lastModified}`;
const result = results.find(item => item.id === id);
const outputName = `${stripExtension(file.name)}.${selectedFormat.extension}`;
return (
<div
key={id}
style={{
border: '1px solid var(--border)',
borderRadius: 10,
padding: 14,
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) auto',
gap: 14,
alignItems: 'center',
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)' }}>{file.name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 4 }}>
{file.type || 'Unknown type'} · {(file.size / 1024 / 1024).toFixed(file.size >= 1024 * 1024 ? 2 : 3)} MB
</div>
{result?.status === 'error' && (
<div style={{ fontSize: 12, color: 'var(--danger)', marginTop: 8 }}>{result.error}</div>
)}
{result?.status === 'done' && (
<div style={{ fontSize: 12, color: 'var(--success, #16a34a)', marginTop: 8 }}>
Ready as {outputName}
</div>
)}
{result?.status === 'converting' && (
<div style={{ fontSize: 12, color: 'var(--accent)', marginTop: 8 }}>Converting...</div>
)}
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
{result?.status === 'done' && result.blob && (
<button
className="btn btn-outline btn-sm"
onClick={() => downloadBlob(result.blob, outputName)}
>
Download
</button>
)}
<button className="btn btn-outline btn-sm" onClick={() => removeFile(index)}>
Remove
</button>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</Layout>
);
}
+213 -75
View File
@@ -1,13 +1,34 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout'; import Layout from '../../components/Layout';
import LoadingButton from '../../components/LoadingButton';
import { supabase } from '../../lib/supabase'; import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/AuthContext';
import { generateInvoicePDF } from '../../lib/invoice';
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
import { withTimeout } from '../../lib/withTimeout';
// Computed at module load time — stable for the lifetime of the invoice creation session
const INVOICE_TODAY = new Date().toISOString().split('T')[0];
const INVOICE_NET30 = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
function newItem(description = '', unit_price = '', quantity = 1, task_id = null, submission_id = null) { function newItem(description = '', unit_price = '', quantity = 1, task_id = null, submission_id = null) {
return { id: crypto.randomUUID(), description, unit_price, quantity, task_id, submission_id }; return { id: crypto.randomUUID(), description, unit_price, quantity, task_id, submission_id };
} }
const buildNewItemDescription = (task) => {
const projectName = task.project?.name || 'No Project';
const taskTitle = task.title || task.service_type || 'Untitled';
return `${projectName}${taskTitle}`;
};
const buildRevisionItemDescription = (revision) => {
const projectName = revision.task?.project?.name || 'No Project';
const taskTitle = revision.task?.title || revision.service_type || 'Revision';
const versionLabel = 'R' + String(revision.version_number || 0).padStart(2, '0');
return `${projectName}${taskTitle} • Revision ${versionLabel}`;
};
export default function CreateInvoice() { export default function CreateInvoice() {
const navigate = useNavigate(); const navigate = useNavigate();
const { currentUser } = useAuth(); const { currentUser } = useAuth();
@@ -17,28 +38,56 @@ export default function CreateInvoice() {
const [uninvoicedTasks, setUninvoicedTasks] = useState([]); const [uninvoicedTasks, setUninvoicedTasks] = useState([]);
const [uninvoicedRevisions, setUninvoicedRevisions] = useState([]); const [uninvoicedRevisions, setUninvoicedRevisions] = useState([]);
const [priceList, setPriceList] = useState([]); const [priceList, setPriceList] = useState([]);
const [billTo, setBillTo] = useState('');
const [invoiceEmail, setInvoiceEmail] = useState('');
const [companyRecipients, setCompanyRecipients] = useState([]);
const [items, setItems] = useState([newItem()]); const [items, setItems] = useState([newItem()]);
const [notes, setNotes] = useState(''); const [notes, setNotes] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [loadingTasks, setLoadingTasks] = useState(false); const [loadingTasks, setLoadingTasks] = useState(false);
const dragItem = useRef(null); const dragItem = useRef(null);
const dragOver = useRef(null); const today = INVOICE_TODAY;
const net30 = INVOICE_NET30;
const today = new Date().toISOString().split('T')[0];
const net30 = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
useEffect(() => { useEffect(() => {
supabase.from('companies').select('id, name').order('name').then(({ data }) => setCompanies(data || [])); supabase.from('companies').select('id, name, contact_email').order('name').then(({ data }) => setCompanies(data || []));
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!selectedCompanyId) { setUninvoicedTasks([]); setUninvoicedRevisions([]); setPriceList([]); setItems([newItem()]); return; } if (!selectedCompanyId) {
setUninvoicedTasks([]);
setUninvoicedRevisions([]);
setPriceList([]);
setItems([newItem()]);
setBillTo('');
setInvoiceEmail('');
setCompanyRecipients([]);
return;
}
setBillTo(companies.find(c => c.id === selectedCompanyId)?.name || '');
setLoadingTasks(true); setLoadingTasks(true);
Promise.all([ Promise.all([
supabase.from('projects').select('id').eq('company_id', selectedCompanyId), supabase.from('projects').select('id').eq('company_id', selectedCompanyId),
supabase.from('company_prices').select('*').eq('company_id', selectedCompanyId), supabase.from('company_prices').select('*').eq('company_id', selectedCompanyId),
]).then(async ([{ data: projects }, { data: prices }]) => { supabase.from('company_members').select('profile:profiles(id, name, email, role, company_id)').eq('company_id', selectedCompanyId),
supabase.from('profiles').select('id, name, email, role, company_id').eq('company_id', selectedCompanyId).in('role', ['client', 'external']).order('name'),
]).then(async ([{ data: projects }, { data: prices }, { data: memberRows }, { data: primaryUsers }]) => {
setPriceList(prices || []); setPriceList(prices || []);
const recipientMap = new Map();
(memberRows || []).forEach(row => {
if (row.profile?.email) recipientMap.set(row.profile.id, row.profile);
});
(primaryUsers || []).forEach(user => {
if (user.email) recipientMap.set(user.id, user);
});
const recipients = [...recipientMap.values()]
.sort((a, b) => {
if (a.role === 'client' && b.role !== 'client') return -1;
if (a.role !== 'client' && b.role === 'client') return 1;
return (a.name || '').localeCompare(b.name || '');
});
setCompanyRecipients(recipients);
setInvoiceEmail(recipients[0]?.email || companies.find(c => c.id === selectedCompanyId)?.contact_email || '');
if (projects && projects.length > 0) { if (projects && projects.length > 0) {
const projectIds = projects.map(p => p.id); const projectIds = projects.map(p => p.id);
const [{ data: tasks }, { data: revisions }] = await Promise.all([ const [{ data: tasks }, { data: revisions }] = await Promise.all([
@@ -69,13 +118,11 @@ export default function CreateInvoice() {
} }
setLoadingTasks(false); setLoadingTasks(false);
}); });
}, [selectedCompanyId]); }, [selectedCompanyId, companies]);
const addTaskAsItem = (task) => { const addTaskAsItem = (task) => {
const price = priceList.find(p => p.service_type === task.service_type && p.price_type === 'new'); const price = priceList.find(p => p.service_type === task.service_type && p.price_type === 'new');
const description = task.service_type && task.service_type !== task.title const description = buildNewItemDescription(task);
? `${task.service_type}${task.title}`
: task.title;
setItems(prev => { setItems(prev => {
if (prev.length === 1 && !prev[0].description && !prev[0].unit_price) { if (prev.length === 1 && !prev[0].description && !prev[0].unit_price) {
return [newItem(description, price?.price || '', 1, task.id)]; return [newItem(description, price?.price || '', 1, task.id)];
@@ -90,12 +137,8 @@ export default function CreateInvoice() {
}; };
const addRevisionAsItem = (revision) => { const addRevisionAsItem = (revision) => {
const versionLabel = 'v' + String((revision.version_number || 1) - 1).padStart(2, '0');
const serviceLabel = getRevisionServiceType(revision); const serviceLabel = getRevisionServiceType(revision);
const taskTitle = revision.task?.title; const description = buildRevisionItemDescription(revision);
const description = serviceLabel && taskTitle && serviceLabel !== taskTitle
? `${serviceLabel}${taskTitle} — Revision ${versionLabel}`
: `${serviceLabel || taskTitle || 'Revision'} — Revision ${versionLabel}`;
const price = priceList.find(p => p.service_type === serviceLabel && p.price_type === 'revision'); const price = priceList.find(p => p.service_type === serviceLabel && p.price_type === 'revision');
setItems(prev => { setItems(prev => {
if (prev.length === 1 && !prev[0].description && !prev[0].unit_price) { if (prev.length === 1 && !prev[0].description && !prev[0].unit_price) {
@@ -137,52 +180,129 @@ export default function CreateInvoice() {
const handleSave = async (status) => { const handleSave = async (status) => {
if (!selectedCompanyId) return alert('Please select a company.'); if (!selectedCompanyId) return alert('Please select a company.');
if (items.every(i => !i.description)) return alert('Please add at least one line item.'); if (items.every(i => !i.description)) return alert('Please add at least one line item.');
if (status === 'sent' && !invoiceEmail.trim()) return alert('Please enter an email recipient before finalizing and sending.');
if (status === 'sent' && !selectedCompany) return alert('Please select a valid company before finalizing and sending.');
setSaving(true); setSaving(true);
try {
const year = new Date().getFullYear();
const { count, error: countError } = await supabase
.from('invoices')
.select('*', { count: 'exact', head: true })
.gte('created_at', `${year}-01-01`);
if (countError) throw countError;
const year = new Date().getFullYear(); const invoiceNumber = `INV-${year}-${String((count || 0) + 1).padStart(3, '0')}`;
const { count } = await supabase.from('invoices').select('*', { count: 'exact', head: true }).gte('created_at', `${year}-01-01`); const initialStatus = status === 'sent' ? 'draft' : status;
const invoiceNumber = `INV-${year}-${String((count || 0) + 1).padStart(3, '0')}`; const { data: invoice, error } = await supabase.from('invoices').insert({
company_id: selectedCompanyId,
invoice_number: invoiceNumber,
invoice_date: today,
due_date: net30,
status: initialStatus,
bill_to: billTo || null,
invoice_email: invoiceEmail.trim() || null,
notes: notes || null,
total,
created_by: currentUser?.id,
}).select().single();
if (error || !invoice) throw error || new Error('Invoice record was not created.');
const { data: invoice, error } = await supabase.from('invoices').insert({ const validItems = items.filter(i => i.description);
company_id: selectedCompanyId, if (validItems.length > 0) {
invoice_number: invoiceNumber, const { error: itemError } = await supabase.from('invoice_items').insert(
invoice_date: today, validItems.map(item => ({
due_date: net30, invoice_id: invoice.id,
status, task_id: item.task_id || null,
notes: notes || null, submission_id: item.submission_id || null,
total, description: item.description,
created_by: currentUser?.id, quantity: Number(item.quantity) || 1,
}).select().single(); unit_price: Number(item.unit_price) || 0,
}))
);
if (itemError) throw itemError;
}
if (error || !invoice) { setSaving(false); alert('Error saving invoice.'); return; } const taskIds = [...new Set(validItems.filter(i => i.task_id && !i.submission_id).map(i => i.task_id))];
if (taskIds.length > 0) {
const { error: taskError } = await supabase.from('tasks').update({ invoiced: true }).in('id', taskIds);
if (taskError) throw taskError;
}
const validItems = items.filter(i => i.description); const submissionIds = [...new Set(validItems.filter(i => i.submission_id).map(i => i.submission_id))];
if (validItems.length > 0) { if (submissionIds.length > 0) {
await supabase.from('invoice_items').insert( const { error: submissionError } = await supabase.from('submissions').update({ invoiced: true }).in('id', submissionIds);
validItems.map(item => ({ if (submissionError) throw submissionError;
invoice_id: invoice.id, }
task_id: item.task_id || null,
submission_id: item.submission_id || null, let nextInvoice = invoice;
description: item.description, if (status === 'sent') {
quantity: Number(item.quantity) || 1, try {
unit_price: Number(item.unit_price) || 0, const dueDate = new Date(net30).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
})) const payUrl = `https://portal.fourgebranding.com/pay/${encodeURIComponent(invoiceNumber)}`;
); const invoiceForPdf = { ...invoice, status: 'sent' };
const pdfItems = validItems.map(item => ({
description: item.description,
quantity: Number(item.quantity) || 1,
unit_price: Number(item.unit_price) || 0,
}));
const emailData = {
invoiceNumber,
billTo: billTo || selectedCompany.name,
total: `$${total.toFixed(2)}`,
dueDate,
payUrl,
notes: notes || '',
};
let attachments = [];
let attachmentWarning = '';
try {
const invoicePdf = await withTimeout(
generateInvoicePDF(invoiceForPdf, selectedCompany, pdfItems, { save: false }),
8000,
'Invoice PDF generation'
);
const attachment = await withTimeout(
blobToEmailAttachment(invoicePdf, `${invoiceNumber}.pdf`),
5000,
'Invoice attachment encoding'
);
attachments = [attachment];
} catch (attachmentError) {
console.error('Invoice PDF attachment skipped during creation:', attachmentError);
attachmentWarning = ' The invoice email was sent without the PDF attachment.';
}
await withTimeout(
sendEmail('invoice_sent', invoiceEmail.trim(), emailData, attachments),
12000,
'Sending invoice email'
);
const { data: sentInvoice, error: sentError } = await supabase
.from('invoices')
.update({ status: 'sent' })
.eq('id', invoice.id)
.select()
.single();
if (sentError) throw sentError;
nextInvoice = sentInvoice || { ...invoice, status: 'sent' };
if (attachmentWarning) {
alert(`Invoice sent successfully.${attachmentWarning}`);
}
} catch (sendError) {
console.error('Failed to send invoice during creation:', sendError);
alert(`Invoice was saved as a draft, but the email was not sent: ${sendError.message}`);
}
}
navigate(`/invoices/${invoice.id}`, { state: { invoice: nextInvoice } });
} catch (saveError) {
console.error('Failed to save invoice:', saveError);
alert(`Failed to save invoice: ${saveError.message || 'Unknown error'}`);
} finally {
setSaving(false);
} }
// Mark tasks as invoiced (only items without a submission_id are base task items)
const taskIds = validItems.filter(i => i.task_id && !i.submission_id).map(i => i.task_id);
if (taskIds.length > 0) {
await supabase.from('tasks').update({ invoiced: true }).in('id', taskIds);
}
// Mark client revisions as invoiced
const submissionIds = validItems.filter(i => i.submission_id).map(i => i.submission_id);
if (submissionIds.length > 0) {
await supabase.from('submissions').update({ invoiced: true }).in('id', submissionIds);
}
navigate(`/invoices/${invoice.id}`, { state: { invoice } });
}; };
const selectedCompany = companies.find(c => c.id === selectedCompanyId); const selectedCompany = companies.find(c => c.id === selectedCompanyId);
@@ -196,12 +316,6 @@ export default function CreateInvoice() {
<div className="page-title">New Invoice</div> <div className="page-title">New Invoice</div>
<div className="page-subtitle">Invoice date: {new Date(today).toLocaleDateString()} · Due: {new Date(net30).toLocaleDateString()} (Net 30)</div> <div className="page-subtitle">Invoice date: {new Date(today).toLocaleDateString()} · Due: {new Date(net30).toLocaleDateString()} (Net 30)</div>
</div> </div>
<div className="action-buttons">
<button className="btn btn-outline btn-sm" onClick={() => handleSave('draft')} disabled={saving}>Save Draft</button>
<button className="btn btn-primary btn-sm" onClick={() => handleSave('sent')} disabled={saving}>
{saving ? 'Saving...' : 'Finalise & Send'}
</button>
</div>
</div> </div>
<div className="card" style={{ marginBottom: 24 }}> <div className="card" style={{ marginBottom: 24 }}>
@@ -216,8 +330,33 @@ export default function CreateInvoice() {
</select> </select>
</div> </div>
{selectedCompany && ( {selectedCompany && (
<div style={{ padding: '12px 14px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)', fontSize: 13 }}> <div className="grid-2" style={{ marginTop: 12 }}>
<div style={{ fontWeight: 600 }}>{selectedCompany.name}</div> <div className="form-group" style={{ marginBottom: 0 }}>
<label>Bill To</label>
<input
type="text"
value={billTo}
onChange={e => setBillTo(e.target.value)}
placeholder={selectedCompany.name}
/>
</div>
<div className="form-group" style={{ marginBottom: 0 }}>
<label>Email To</label>
<input
type="email"
list="company-invoice-recipients"
value={invoiceEmail}
onChange={e => setInvoiceEmail(e.target.value)}
placeholder={companyRecipients[0]?.email || 'client@example.com'}
/>
<datalist id="company-invoice-recipients">
{companyRecipients.map(recipient => (
<option key={recipient.id} value={recipient.email}>
{recipient.name ? `${recipient.name} (${recipient.role})` : recipient.role}
</option>
))}
</datalist>
</div>
</div> </div>
)} )}
</div> </div>
@@ -248,8 +387,10 @@ export default function CreateInvoice() {
return ( return (
<div key={task.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 6, border: '1px solid var(--border)' }}> <div key={task.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 6, border: '1px solid var(--border)' }}>
<div> <div>
<div style={{ fontSize: 13, fontWeight: 600 }}>{task.service_type && task.service_type !== task.title ? `${task.service_type}${task.title}` : task.title}</div> <div style={{ fontSize: 13, fontWeight: 600 }}>{buildNewItemDescription(task)}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{task.project?.name} · {price ? `$${Number(price.price).toFixed(2)}` : 'No price set'}</div> <div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
{task.service_type || 'Other'} {price ? `$${Number(price.price).toFixed(2)}` : 'No price set'}
</div>
</div> </div>
<button <button
className={`btn btn-sm ${alreadyAdded ? 'btn-outline' : 'btn-primary'}`} className={`btn btn-sm ${alreadyAdded ? 'btn-outline' : 'btn-primary'}`}
@@ -283,19 +424,16 @@ export default function CreateInvoice() {
) : ( ) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{uninvoicedRevisions.map(rev => { {uninvoicedRevisions.map(rev => {
const versionLabel = 'v' + String((rev.version_number || 1) - 1).padStart(2, '0');
const revServiceType = getRevisionServiceType(rev); const revServiceType = getRevisionServiceType(rev);
const price = priceList.find(p => p.service_type === revServiceType && p.price_type === 'revision'); const price = priceList.find(p => p.service_type === revServiceType && p.price_type === 'revision');
const alreadyAdded = items.some(i => i.submission_id === rev.id); const alreadyAdded = items.some(i => i.submission_id === rev.id);
return ( return (
<div key={rev.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 6, border: '1px solid var(--border)' }}> <div key={rev.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 6, border: '1px solid var(--border)' }}>
<div> <div>
<div style={{ fontSize: 13, fontWeight: 600 }}> <div style={{ fontSize: 13, fontWeight: 600 }}>{buildRevisionItemDescription(rev)}</div>
{revServiceType && rev.task?.title && revServiceType !== rev.task?.title <div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
? `${revServiceType}${rev.task.title} — Revision ${versionLabel}` {revServiceType || 'Other'} {price ? `$${Number(price.price).toFixed(2)}` : 'No price set'}
: `${revServiceType || rev.task?.title || 'Revision'} — Revision ${versionLabel}`}
</div> </div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{rev.task?.project?.name} · {price ? `$${Number(price.price).toFixed(2)}` : 'No price set'}</div>
</div> </div>
<button <button
className={`btn btn-sm ${alreadyAdded ? 'btn-outline' : 'btn-primary'}`} className={`btn btn-sm ${alreadyAdded ? 'btn-outline' : 'btn-primary'}`}
@@ -381,9 +519,9 @@ export default function CreateInvoice() {
<div className="action-buttons"> <div className="action-buttons">
<button className="btn btn-outline" onClick={() => handleSave('draft')} disabled={saving}>Save Draft</button> <button className="btn btn-outline" onClick={() => handleSave('draft')} disabled={saving}>Save Draft</button>
<button className="btn btn-primary" onClick={() => handleSave('sent')} disabled={saving}> <LoadingButton className="btn btn-primary" onClick={() => handleSave('sent')} loading={saving} loadingText="Finalizing & Sending...">
{saving ? 'Saving...' : 'Finalise & Send'} Finalise & Send
</button> </LoadingButton>
<button className="btn btn-outline" onClick={() => navigate('/invoices')}>Cancel</button> <button className="btn btn-outline" onClick={() => navigate('/invoices')}>Cancel</button>
</div> </div>
</Layout> </Layout>
+393
View File
@@ -0,0 +1,393 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout';
import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext';
import { sendEmail } from '../../lib/email';
const FIELD_LABEL_STYLE = { fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', marginBottom: 4 };
const FIELD_INPUT_STYLE = { minHeight: 42, margin: 0 };
const blankSubcontractorPO = () => ({
profile_id: '',
project_id: '',
date: new Date().toISOString().slice(0, 10),
due_date: '',
description: '',
terms: 'Net 15',
notes: '',
line_items: [{ task_id: '', description: '', amount: '' }],
});
function getTaskServiceType(task) {
const initial = (task?.submissions || []).find(submission => submission.type === 'initial') || (task?.submissions || [])[0];
return initial?.service_type || task?.service_type || task?.title || 'Work Item';
}
export default function CreateSubcontractorPO() {
const navigate = useNavigate();
const { currentUser } = useAuth();
const [externalProfiles, setExternalProfiles] = useState([]);
const [projects, setProjects] = useState([]);
const [projectTasks, setProjectTasks] = useState([]);
const [purchaseOrders, setPurchaseOrders] = useState([]);
const [usedTaskItems, setUsedTaskItems] = useState([]);
const [form, setForm] = useState(blankSubcontractorPO());
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function load() {
const [{ data: subcontractors }, { data: projectRows }, { data: taskRows }, { data: poRows }, { data: usedRows }] = await Promise.all([
supabase.from('profiles').select('id, name, email').eq('role', 'external').order('name'),
supabase.from('projects').select('id, name, company:companies(name)').order('created_at', { ascending: false }),
supabase.from('tasks').select('id, project_id, title, status, submissions(service_type, type)').order('submitted_at', { ascending: false }),
supabase.from('subcontractor_payments').select('po_number').order('created_at', { ascending: false }),
supabase
.from('subcontractor_po_items')
.select('task_id, po:subcontractor_payments!inner(id, po_number, status, profile:profiles!subcontractor_payments_profile_id_fkey(name, email))')
.not('task_id', 'is', null)
.neq('po.status', 'cancelled'),
]);
setExternalProfiles(subcontractors || []);
setProjects(projectRows || []);
setProjectTasks(taskRows || []);
setPurchaseOrders(poRows || []);
setUsedTaskItems(usedRows || []);
setLoading(false);
}
load();
}, []);
const nextPONumber = () => {
const year = new Date().getFullYear();
const sameYear = purchaseOrders
.map(po => po.po_number)
.filter(number => typeof number === 'string' && number.startsWith(`PO-${year}-`));
const max = sameYear.reduce((highest, number) => {
const value = Number(number.split('-').pop());
return Number.isFinite(value) ? Math.max(highest, value) : highest;
}, 0);
return `PO-${year}-${String(max + 1).padStart(4, '0')}`;
};
const filteredTasks = form.project_id
? projectTasks.filter(task => task.project_id === form.project_id)
: [];
const availableTasks = filteredTasks.filter(task => !usedTaskItems.some(item => item.task_id === task.id));
const getUsedTaskItem = (taskId) => usedTaskItems.find(item => item.task_id === taskId);
const getValidLineItems = () => form.line_items
.map((item, index) => {
const task = projectTasks.find(row => row.id === item.task_id);
return {
task_id: item.task_id || null,
description: item.description.trim() || (task ? getTaskServiceType(task) : ''),
amount: Number(item.amount),
sort_order: index,
};
})
.filter(item => item.description && Number.isFinite(item.amount) && item.amount > 0);
const total = getValidLineItems().reduce((sum, item) => sum + item.amount, 0);
const handleProjectChange = (projectId) => {
setForm(prev => ({
...prev,
project_id: projectId,
line_items: [{ task_id: '', description: '', amount: '' }],
}));
};
const updateLineItem = (index, updates) => {
setForm(prev => ({
...prev,
line_items: prev.line_items.map((item, itemIndex) => itemIndex === index ? { ...item, ...updates } : item),
}));
};
const handleLineTaskChange = (index, taskId) => {
const task = projectTasks.find(row => row.id === taskId);
updateLineItem(index, { task_id: taskId, description: task ? getTaskServiceType(task) : '' });
};
const addTaskAsLineItem = (task) => {
const nextItem = { task_id: task.id, description: getTaskServiceType(task), amount: '' };
setForm(prev => {
if (prev.line_items.length === 1 && !prev.line_items[0].description && !prev.line_items[0].amount) {
return { ...prev, line_items: [nextItem] };
}
if (prev.line_items.some(item => item.task_id === task.id)) return prev;
return { ...prev, line_items: [...prev.line_items, nextItem] };
});
};
const addLineItem = () => {
setForm(prev => ({
...prev,
line_items: [...prev.line_items, { task_id: '', description: '', amount: '' }],
}));
};
const removeLineItem = (index) => {
setForm(prev => ({
...prev,
line_items: prev.line_items.length > 1
? prev.line_items.filter((_, itemIndex) => itemIndex !== index)
: [{ task_id: '', description: '', amount: '' }],
}));
};
const handleSave = async (status = 'draft') => {
const lineItems = getValidLineItems();
if (!form.profile_id) return alert('Please select a subcontractor.');
if (lineItems.length === 0) return alert('Please add at least one line item with an amount.');
setSaving(true);
const summary = form.description.trim() || lineItems.map(item => item.description).join(', ');
const { data, error } = await supabase
.from('subcontractor_payments')
.insert({
po_number: nextPONumber(),
profile_id: form.profile_id,
project_id: form.project_id || null,
date: form.date,
due_date: form.due_date || null,
description: summary,
amount: total,
terms: form.terms.trim() || 'Net 15',
notes: form.notes.trim(),
status,
sent_at: status === 'sent' ? new Date().toISOString() : null,
created_by: currentUser?.id || null,
})
.select()
.single();
if (error || !data) {
setSaving(false);
alert(`Failed to create PO: ${error?.message || 'unknown error'}`);
return;
}
const { error: itemError } = await supabase
.from('subcontractor_po_items')
.insert(lineItems.map(item => ({ ...item, po_id: data.id })));
setSaving(false);
if (itemError) {
alert(`PO was created, but line items failed: ${itemError.message}`);
return;
}
if (status === 'sent') {
const subcontractor = externalProfiles.find(profile => profile.id === form.profile_id);
const project = projects.find(row => row.id === form.project_id);
if (subcontractor?.email) {
try {
await sendEmail('subcontractor_po_sent', subcontractor.email, {
poNumber: data.po_number || 'Purchase Order',
subcontractorName: subcontractor.name || 'there',
projectName: project?.name || 'Subcontractor Work',
companyName: project?.company?.name || 'Fourge Branding',
amount: `$${total.toFixed(2)}`,
dueDate: form.due_date ? new Date(form.due_date).toLocaleDateString() : 'Not set',
terms: form.terms.trim() || 'Net 15',
scope: lineItems.map(item => `${item.description} - $${item.amount.toFixed(2)}`).join('\n'),
portalUrl: 'https://portal.fourgebranding.com/my-purchase-orders',
});
} catch (emailError) {
alert(`PO was finalized, but the email failed: ${emailError.message || 'unknown error'}`);
}
} else {
alert('PO was finalized, but this subcontractor has no email on file.');
}
}
navigate('/invoices', { state: { tab: 'subcontractor-po' } });
};
return (
<Layout>
<button className="back-link" onClick={() => navigate('/invoices', { state: { tab: 'subcontractor-po' } })}> Back to Subcontractor PO</button>
<div className="page-header">
<div>
<div className="page-title">New Subcontractor PO</div>
<div className="page-subtitle">Create a draft purchase order for subcontractor work.</div>
</div>
</div>
<div className="card" style={{ marginBottom: 24 }}>
<div className="card-title">Subcontractor</div>
<div className="grid-2">
<div className="form-group" style={{ marginBottom: 0 }}>
<label>External Team Member *</label>
<select value={form.profile_id} onChange={e => setForm(prev => ({ ...prev, profile_id: e.target.value }))}>
<option value="">Choose a subcontractor...</option>
{externalProfiles.map(profile => (
<option key={profile.id} value={profile.id}>
{profile.name || profile.email} {profile.name && profile.email ? `(${profile.email})` : ''}
</option>
))}
</select>
</div>
<div className="form-group" style={{ marginBottom: 0 }}>
<label>Project</label>
<select value={form.project_id} onChange={e => handleProjectChange(e.target.value)}>
<option value="">No project</option>
{projects.map(project => (
<option key={project.id} value={project.id}>
{project.name}{project.company?.name ? ` · ${project.company.name}` : ''}
</option>
))}
</select>
</div>
</div>
</div>
{form.project_id && (
<div className="card" style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<div className="card-title" style={{ marginBottom: 0 }}>Project Tasks</div>
{availableTasks.length > 0 && (
<button className="btn btn-outline btn-sm" onClick={() => availableTasks.forEach(task => addTaskAsLineItem(task))}>
+ Add All
</button>
)}
</div>
{filteredTasks.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>No tasks found for this project.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{filteredTasks.map(task => {
const alreadyAdded = form.line_items.some(item => item.task_id === task.id);
const usedItem = getUsedTaskItem(task.id);
const usedBy = usedItem?.po?.profile?.name || usedItem?.po?.profile?.email || 'another PO';
return (
<div key={task.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 6, border: '1px solid var(--border)' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 700 }}>{task.title}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
{usedItem
? `Already on ${usedItem.po?.po_number || 'a PO'} for ${usedBy}`
: getTaskServiceType(task)}
</div>
</div>
<button
className={`btn btn-sm ${alreadyAdded || usedItem ? 'btn-outline' : 'btn-primary'}`}
onClick={() => !alreadyAdded && !usedItem && addTaskAsLineItem(task)}
disabled={alreadyAdded || Boolean(usedItem)}
>
{usedItem ? 'Already Used' : alreadyAdded ? 'Added' : '+ Add'}
</button>
</div>
);
})}
</div>
)}
</div>
)}
<div className="card" style={{ marginBottom: 24 }}>
<div className="card-title">Line Items</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 120px 40px', gap: 8, marginBottom: 8 }}>
{['Description', 'Pay Amount', ''].map((header, index) => (
<div key={header} style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', textAlign: index > 0 ? 'right' : 'left' }}>{header}</div>
))}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{form.line_items.map((item, index) => (
<div key={index} style={{ display: 'grid', gridTemplateColumns: '1fr 120px 40px', gap: 8, alignItems: 'start' }}>
<div style={{ display: 'grid', gap: 8 }}>
{form.project_id && (
<select
value={item.task_id}
onChange={e => handleLineTaskChange(index, e.target.value)}
style={{ margin: 0 }}
>
<option value="">Custom line item...</option>
{availableTasks.map(task => (
<option key={task.id} value={task.id}>{task.title}</option>
))}
</select>
)}
<input
type="text"
placeholder="Description..."
value={item.description}
onChange={e => updateLineItem(index, { description: e.target.value })}
style={{ margin: 0 }}
/>
</div>
<input
type="number"
min="0"
step="0.01"
placeholder="0.00"
value={item.amount}
onChange={e => updateLineItem(index, { amount: e.target.value })}
style={{ margin: 0, textAlign: 'right' }}
/>
<button onClick={() => removeLineItem(index)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--danger)', fontSize: 16, padding: 4 }}></button>
</div>
))}
</div>
<button className="btn btn-outline btn-sm" style={{ marginTop: 12 }} onClick={addLineItem}>
+ Add Line Item
</button>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 20, paddingTop: 16, borderTop: '1px solid var(--border)' }}>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>Total</div>
<div style={{ fontSize: 26, fontWeight: 700, color: 'var(--accent)' }}>${total.toFixed(2)}</div>
</div>
</div>
</div>
<div className="card" style={{ marginBottom: 24 }}>
<div className="card-title">PO Details</div>
<div className="grid-2">
<div className="form-group">
<label style={FIELD_LABEL_STYLE}>PO Date</label>
<input type="date" value={form.date} onChange={e => setForm(prev => ({ ...prev, date: e.target.value }))} style={FIELD_INPUT_STYLE} />
</div>
<div className="form-group">
<label style={FIELD_LABEL_STYLE}>Due Date</label>
<input type="date" value={form.due_date} onChange={e => setForm(prev => ({ ...prev, due_date: e.target.value }))} style={FIELD_INPUT_STYLE} />
</div>
</div>
<div className="form-group">
<label style={FIELD_LABEL_STYLE}>Terms</label>
<input type="text" value={form.terms} onChange={e => setForm(prev => ({ ...prev, terms: e.target.value }))} />
</div>
</div>
<div className="card" style={{ marginBottom: 24 }}>
<div className="card-title">Notes</div>
<textarea
placeholder="Optional overview, special instructions, payment notes..."
value={form.description}
onChange={e => setForm(prev => ({ ...prev, description: e.target.value }))}
style={{ minHeight: 80 }}
/>
<input
type="text"
placeholder="Internal notes, payment method, invoice number..."
value={form.notes}
onChange={e => setForm(prev => ({ ...prev, notes: e.target.value }))}
style={{ marginTop: 12 }}
/>
</div>
<div className="action-buttons" style={{ justifyContent: 'flex-start', marginBottom: 32 }}>
<button className="btn btn-outline" onClick={() => handleSave('draft')} disabled={saving || loading}>
{saving ? 'Saving...' : 'Save Draft'}
</button>
<button className="btn btn-primary" onClick={() => handleSave('sent')} disabled={saving || loading}>
{saving ? 'Saving...' : 'Finalize & Send'}
</button>
</div>
</Layout>
);
}
+297 -93
View File
@@ -4,12 +4,85 @@ import Layout from '../../components/Layout';
import StatusBadge from '../../components/StatusBadge'; import StatusBadge from '../../components/StatusBadge';
import { supabase } from '../../lib/supabase'; import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/AuthContext';
import { readPageCache, writePageCache } from '../../lib/pageCache';
import { withTimeout } from '../../lib/withTimeout';
import { getDeadlineSourceSubmission } from '../../lib/taskDeadlines';
import { formatDateOnly, parseDateOnly } from '../../lib/dates';
function formatDeadline(value) {
return formatDateOnly(value, 'No deadline');
}
function getDeadlineMeta(value) {
const date = parseDateOnly(value);
if (!date) return null;
const today = new Date();
today.setHours(0, 0, 0, 0);
const diffDays = Math.round((date.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
if (diffDays < 0) return { label: `${Math.abs(diffDays)} day${Math.abs(diffDays) === 1 ? '' : 's'} overdue`, color: 'var(--danger)' };
if (diffDays === 0) return { label: 'Due today', color: '#f97316' };
if (diffDays === 1) return { label: 'Due tomorrow', color: '#f5a523' };
return { label: `Due in ${diffDays} days`, color: 'var(--text-muted)' };
}
function TaskListCard({ title, subtitle, tasks, projects, emptyMessage }) {
return (
<div className="card">
<div className="card-title" style={{ marginBottom: 6 }}>{title}</div>
{subtitle && <div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 14 }}>{subtitle}</div>}
{tasks.length === 0 ? (
<div style={{ color: 'var(--text-muted)', fontSize: 13 }}>{emptyMessage}</div>
) : (
<div style={{ display: 'grid', gap: 10 }}>
{tasks.map(task => {
const project = projects.find(p => p.id === task.project_id);
const deadlineMeta = getDeadlineMeta(task.deadline);
return (
<Link
key={task.id}
to={`/tasks/${task.id}`}
className="interactive-row"
style={{
border: '1px solid var(--border)',
borderRadius: 8,
padding: '12px 14px',
textDecoration: 'none',
display: 'grid',
gap: 6,
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)' }}>{task.title}</div>
<StatusBadge status={task.status} />
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{project?.name || 'No project'}{task.assigned_name ? ` · ${task.assigned_name}` : ''}
</div>
<div style={{ fontSize: 12, color: deadlineMeta?.color || 'var(--text-muted)', fontWeight: deadlineMeta ? 600 : 500 }}>
{formatDeadline(task.deadline)}
{deadlineMeta ? ` · ${deadlineMeta.label}` : ''}
</div>
</div>
</Link>
);
})}
</div>
)}
</div>
);
}
function CompanyGroup({ company, tasks, projects }) { function CompanyGroup({ company, tasks, projects }) {
const [open, setOpen] = useState(true); const [open, setOpen] = useState(true);
return ( return (
<div style={{ marginBottom: 10, border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden', background: 'var(--card-bg)' }}> <div className="interactive-surface" style={{ marginBottom: 10, border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden', background: 'var(--card-bg)' }}>
<button <button
className="interactive-panel-toggle"
onClick={() => setOpen(o => !o)} onClick={() => setOpen(o => !o)}
style={{ style={{
width: '100%', display: 'flex', alignItems: 'center', width: '100%', display: 'flex', alignItems: 'center',
@@ -34,10 +107,10 @@ function CompanyGroup({ company, tasks, projects }) {
{tasks.map(task => { {tasks.map(task => {
const project = projects.find(p => p.id === task.project_id); const project = projects.find(p => p.id === task.project_id);
return ( return (
<Link key={task.id} to={`/tasks/${task.id}`} style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)', display: 'flex', flexDirection: 'column', gap: 4, textDecoration: 'none', cursor: 'pointer' }}> <Link key={task.id} to={`/tasks/${task.id}`} className="interactive-row" style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)', display: 'flex', flexDirection: 'column', gap: 4, textDecoration: 'none', cursor: 'pointer' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}> <span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>
{task.title} <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>{'v' + String(task.current_version || 0).padStart(2, '0')}</span> {task.title} <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>{'R' + String(task.current_version || 0).padStart(2, '0')}</span>
</span> </span>
<StatusBadge status={task.status} /> <StatusBadge status={task.status} />
</div> </div>
@@ -59,8 +132,9 @@ function CompanyGroup({ company, tasks, projects }) {
function ProjectGroup({ project, tasks }) { function ProjectGroup({ project, tasks }) {
const [open, setOpen] = useState(true); const [open, setOpen] = useState(true);
return ( return (
<div style={{ marginBottom: 10, border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden', background: 'var(--card-bg)' }}> <div className="interactive-surface" style={{ marginBottom: 10, border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden', background: 'var(--card-bg)' }}>
<button <button
className="interactive-panel-toggle"
onClick={() => setOpen(o => !o)} onClick={() => setOpen(o => !o)}
style={{ style={{
width: '100%', display: 'flex', alignItems: 'center', width: '100%', display: 'flex', alignItems: 'center',
@@ -81,9 +155,9 @@ function ProjectGroup({ project, tasks }) {
{open && ( {open && (
<div> <div>
{tasks.map(task => ( {tasks.map(task => (
<a key={task.id} href={`/tasks/${task.id}`} style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', textDecoration: 'none' }}> <a key={task.id} href={`/tasks/${task.id}`} className="interactive-row" style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', textDecoration: 'none' }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}> <span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>
{task.title} <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>{'v' + String(task.current_version || 0).padStart(2, '0')}</span> {task.title} <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>{'R' + String(task.current_version || 0).padStart(2, '0')}</span>
</span> </span>
<StatusBadge status={task.status} /> <StatusBadge status={task.status} />
</a> </a>
@@ -94,6 +168,102 @@ function ProjectGroup({ project, tasks }) {
); );
} }
function OutputCharts({ title, subtitle, taskPeople, revisionPeople }) {
const taskRows = [...(taskPeople || [])].sort((a, b) => b.total - a.total || a.name.localeCompare(b.name));
const revisionRows = [...(revisionPeople || [])].sort((a, b) => b.revisions - a.revisions || a.name.localeCompare(b.name));
const hasData = taskRows.length > 0 || revisionRows.length > 0;
const chartColors = ['#F5A523', '#60A5FA', '#4ADE80', '#F87171', '#C084FC', '#FBBF24', '#22C55E', '#38BDF8'];
const totalTasks = taskRows.reduce((sum, person) => sum + person.total, 0);
const totalRevisions = revisionRows.reduce((sum, person) => sum + person.revisions, 0);
const taskGradient = taskRows.length
? `conic-gradient(${taskRows.map((person, index) => {
const start = (taskRows.slice(0, index).reduce((sum, item) => sum + item.total, 0) / Math.max(totalTasks, 1)) * 100;
const end = ((taskRows.slice(0, index + 1).reduce((sum, item) => sum + item.total, 0)) / Math.max(totalTasks, 1)) * 100;
return `${chartColors[index % chartColors.length]} ${start}% ${end}%`;
}).join(', ')})`
: 'none';
const revisionGradient = totalRevisions > 0
? `conic-gradient(${revisionRows.map((person, index) => {
const start = (revisionRows.slice(0, index).reduce((sum, item) => sum + item.revisions, 0) / totalRevisions) * 100;
const end = ((revisionRows.slice(0, index + 1).reduce((sum, item) => sum + item.revisions, 0)) / totalRevisions) * 100;
return `${chartColors[index % chartColors.length]} ${start}% ${end}%`;
}).join(', ')})`
: 'none';
return (
<div className="card">
<div className="card-title" style={{ marginBottom: 6 }}>{title}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 14 }}>{subtitle}</div>
{!hasData ? (
<div style={{ color: 'var(--text-muted)', fontSize: 13 }}>No completed assigned tasks yet.</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 18 }}>
{[
{ title: 'New Tasks', total: totalTasks, rows: taskRows, valueKey: 'total', gradient: taskGradient },
{ title: 'Revisions', total: totalRevisions, rows: revisionRows, valueKey: 'revisions', gradient: revisionGradient },
].map((chart) => (
<div key={chart.title} style={{ border: '1px solid var(--border)', borderRadius: 8, padding: 16, background: 'var(--card-bg-2)' }}>
<div style={{ display: 'grid', gridTemplateColumns: '140px minmax(0, 1fr)', gap: 18, alignItems: 'center' }}>
<div className="dashboard-pie-wrap">
<div className="dashboard-pie" style={{ background: chart.gradient }}>
<div className="dashboard-pie-center">
<strong>{chart.total}</strong>
<span>{chart.title}</span>
</div>
</div>
</div>
<div className="dashboard-legend">
{chart.rows.map((person, index) => {
const value = person[chart.valueKey];
const percent = chart.total ? Math.round((value / chart.total) * 100) : 0;
return (
<div key={`${chart.title}-${person.name}`} className="dashboard-legend-item">
<span className="dashboard-legend-dot" style={{ background: chartColors[index % chartColors.length] }} />
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{person.name}</span>
<strong>{value} · {percent}%</strong>
</div>
);
})}
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}
function buildTaskPeople(tasks) {
const completedAssignedTasks = tasks.filter(task => task.status === 'client_approved' && task.assigned_name);
return [...completedAssignedTasks.reduce((map, task) => {
const person = task.assigned_name;
const entry = map.get(person) || { name: person, total: 0 };
entry.total += 1;
map.set(person, entry);
return map;
}, new Map()).values()];
}
function buildRevisionPeople(submissions, tasks, roleFilter) {
return [...(submissions || []).reduce((map, submission) => {
if ((submission.version_number || 0) <= 0) return map;
if (!submission.delivery?.sent_by) return map;
if (roleFilter && submission.delivery_sender_role !== roleFilter) return map;
if (!roleFilter && submission.delivery_sender_role === 'external') return map;
const person = submission.delivery.sent_by;
const entry = map.get(person) || { name: person, revisions: 0 };
entry.revisions += 1;
map.set(person, entry);
return map;
}, new Map()).values()];
}
function ExternalDashboard({ currentUser, projects, tasks }) { function ExternalDashboard({ currentUser, projects, tasks }) {
const activeTasks = tasks.filter(t => t.status !== 'client_approved'); const activeTasks = tasks.filter(t => t.status !== 'client_approved');
const completedTasks = tasks.filter(t => t.status === 'client_approved'); const completedTasks = tasks.filter(t => t.status === 'client_approved');
@@ -149,63 +319,63 @@ function ExternalDashboard({ currentUser, projects, tasks }) {
); );
} }
function GroupedColumn({ tasks, companies, projects, emptyText }) {
if (tasks.length === 0) return (
<div style={{ padding: '24px 16px', textAlign: 'center', color: 'var(--text-muted)', fontSize: 13, background: 'var(--card-bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
{emptyText}
</div>
);
const groups = companies
.map(company => {
const companyProjectIds = projects.filter(p => p.company_id === company.id).map(p => p.id);
const companyTasks = tasks.filter(t => companyProjectIds.includes(t.project_id));
return { company, tasks: companyTasks };
})
.filter(g => g.tasks.length > 0);
return (
<div>
{groups.map(({ company, tasks: groupTasks }) => (
<CompanyGroup key={company.id} company={company} tasks={groupTasks} projects={projects} />
))}
</div>
);
}
export default function Dashboard() { export default function Dashboard() {
const { currentUser } = useAuth(); const { currentUser } = useAuth();
const [tasks, setTasks] = useState([]);
const [projects, setProjects] = useState([]);
const [companies, setCompanies] = useState([]);
const [loading, setLoading] = useState(true);
const [showCompleted, setShowCompleted] = useState(false);
const isExternal = currentUser?.role === 'external'; const isExternal = currentUser?.role === 'external';
const cacheKey = isExternal ? 'team_dashboard_external' : 'team_dashboard';
const cached = readPageCache(cacheKey, 5 * 60_000);
const [tasks, setTasks] = useState(() => cached?.tasks || []);
const [projects, setProjects] = useState(() => cached?.projects || []);
const [submissions, setSubmissions] = useState(() => cached?.submissions || []);
const [loading, setLoading] = useState(() => !cached);
useEffect(() => { useEffect(() => {
async function load() { async function load() {
if (isExternal) { try {
const [{ data: p }, { data: t }] = await Promise.all([ if (isExternal) {
supabase.from('projects').select('*').order('created_at', { ascending: false }), const [{ data: p }, { data: t }] = await withTimeout(Promise.all([
supabase.from('tasks').select('*').order('submitted_at', { ascending: false }), supabase.from('projects').select('id, name').order('created_at', { ascending: false }),
]); supabase.from('tasks').select('id, title, status, current_version, project_id').order('submitted_at', { ascending: false }),
setProjects(p || []); ]), 12000, 'Dashboard load');
setTasks(t || []); setProjects(p || []);
} else { setTasks(t || []);
const [{ data: t }, { data: p }, { data: co }] = await Promise.all([ setSubmissions([]);
supabase.from('tasks').select('*').order('submitted_at', { ascending: false }), writePageCache(cacheKey, { projects: p || [], tasks: t || [], submissions: [] });
supabase.from('projects').select('*'), } else {
supabase.from('companies').select('*').order('name'), const [{ data: t }, { data: p }, { data: submissions }, { data: profiles }] = await withTimeout(Promise.all([
]); supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_name, assigned_to, completed_at').order('submitted_at', { ascending: false }),
setTasks(t || []); supabase.from('projects').select('id, name, status, company_id'),
setProjects(p || []); supabase.from('submissions').select('task_id, version_number, deadline, type, submitted_by, submitted_by_name, delivery:deliveries(sent_by, sent_at)').order('version_number', { ascending: false }),
setCompanies(co || []); supabase.from('profiles').select('id, role, name'),
]), 12000, 'Dashboard load');
const roleByProfileId = new Map((profiles || []).map(profile => [profile.id, profile.role]));
const roleByProfileName = new Map((profiles || []).map(profile => [profile.name, profile.role]));
const tasksWithDeadlines = (t || []).map(task => ({
...task,
deadline: getDeadlineSourceSubmission(task, submissions)?.deadline || null,
assignee_role: roleByProfileId.get(task.assigned_to) || null,
}));
const submissionsWithRole = (submissions || []).map(submission => ({
...submission,
submitter_role: roleByProfileId.get(submission.submitted_by) || null,
delivery_sender_role: roleByProfileName.get(submission.delivery?.sent_by) || null,
}));
setTasks(tasksWithDeadlines);
setProjects(p || []);
writePageCache(cacheKey, { tasks: tasksWithDeadlines, projects: p || [], submissions: submissionsWithRole });
setSubmissions(submissionsWithRole);
}
} catch (error) {
console.error('Dashboard load failed:', error);
} }
setLoading(false); setLoading(false);
} }
load(); load();
}, [isExternal]); }, [cacheKey, isExternal]);
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>; if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
@@ -213,11 +383,32 @@ export default function Dashboard() {
return <ExternalDashboard currentUser={currentUser} projects={projects} tasks={tasks} />; return <ExternalDashboard currentUser={currentUser} projects={projects} tasks={tasks} />;
} }
const activeTasks = tasks.filter(t => ['in_progress', 'on_hold', 'client_review'].includes(t.status)); const activeTasks = tasks.filter(t => t.status !== 'client_approved');
const inProgressTasks = tasks.filter(t => t.status === 'in_progress');
const notStartedTasks = tasks.filter(t => t.status === 'not_started'); const notStartedTasks = tasks.filter(t => t.status === 'not_started');
const completedTasks = tasks.filter(t => t.status === 'client_approved'); const onHoldTasks = tasks.filter(t => t.status === 'on_hold');
const activeProjects = projects.filter(p => p.status === 'active'); const reviewTasks = tasks.filter(t => t.status === 'client_review');
const upcomingDeadlineTasks = [...tasks]
.filter(task => task.deadline && task.status !== 'client_approved')
.sort((a, b) => parseDateOnly(a.deadline) - parseDateOnly(b.deadline))
.slice(0, 6);
const assignedToMeTasks = [...tasks]
.filter(task => task.assigned_to === currentUser?.id && task.status !== 'client_approved')
.sort((a, b) => {
const aDate = parseDateOnly(a.deadline);
const bDate = parseDateOnly(b.deadline);
if (aDate && bDate) return aDate - bDate;
if (aDate) return -1;
if (bDate) return 1;
return 0;
})
.slice(0, 6);
const teamOutputTasks = tasks.filter(task => task.assignee_role !== 'external');
const subcontractorOutputTasks = tasks.filter(task => task.assignee_role === 'external');
const teamTaskPeople = buildTaskPeople(teamOutputTasks);
const subcontractorTaskPeople = buildTaskPeople(subcontractorOutputTasks);
const teamRevisionPeople = buildRevisionPeople(submissions, tasks, null);
const subcontractorRevisionPeople = buildRevisionPeople(submissions, tasks, 'external');
return ( return (
<Layout> <Layout>
<div className="page-header"> <div className="page-header">
@@ -225,58 +416,71 @@ export default function Dashboard() {
<div className="page-title">Welcome back, {currentUser?.name?.split(' ')[0]}</div> <div className="page-title">Welcome back, {currentUser?.name?.split(' ')[0]}</div>
<div className="page-subtitle">Here's what's happening across your projects.</div> <div className="page-subtitle">Here's what's happening across your projects.</div>
</div> </div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-outline btn-sm" onClick={() => setShowCompleted(s => !s)}>
{showCompleted ? 'Hide Completed' : 'Show Completed'}
</button>
<Link to="/requests" className="btn btn-primary btn-sm">View Requests</Link>
</div>
</div> </div>
<div className="stats-grid"> <div className="stats-grid">
<div className="stat-card"> <div className="stat-card stat-card-highlight">
<div className="stat-icon">📁</div>
<div className="stat-value">{activeProjects.length}</div>
<div className="stat-label">Active Projects</div>
</div>
<div className="stat-card">
<div className="stat-icon"></div> <div className="stat-icon"></div>
<div className="stat-value">{activeTasks.length}</div> <div className="stat-value">{activeTasks.length}</div>
<div className="stat-label">Active Jobs</div> <div className="stat-label">Active Jobs</div>
</div> </div>
<div className="stat-card"> <div className="stat-card">
<div className="stat-icon">🔴</div> <div className="stat-icon"></div>
<div className="stat-value">{notStartedTasks.length}</div> <div className="stat-value">{notStartedTasks.length}</div>
<div className="stat-label">Not Started</div> <div className="stat-label">Not Started</div>
</div> </div>
<div className="stat-card"> <div className="stat-card">
<div className="stat-icon"></div> <div className="stat-icon"></div>
<div className="stat-value">{completedTasks.length}</div> <div className="stat-value">{inProgressTasks.length}</div>
<div className="stat-label">Completed</div> <div className="stat-label">In Progress</div>
</div>
<div className="stat-card">
<div className="stat-icon"></div>
<div className="stat-value">{onHoldTasks.length}</div>
<div className="stat-label">On Hold</div>
</div>
<div className="stat-card">
<div className="stat-icon">🕓</div>
<div className="stat-value">{reviewTasks.length}</div>
<div className="stat-label">Awaiting Client Review</div>
</div> </div>
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: showCompleted ? '1fr 1fr 1fr' : '1fr 1fr', gap: 24 }}> <div style={{ marginTop: 24 }}>
<div> <OutputCharts
<div className="card-title">Not Started</div> title="Completed By Team Member"
<GroupedColumn tasks={notStartedTasks} companies={companies} projects={projects} emptyText="Nothing waiting to start" /> subtitle="Completed-task output by team assignee, with revisions counted by the person who submitted them."
</div> taskPeople={teamTaskPeople}
<div> revisionPeople={teamRevisionPeople}
<div className="card-title">Active Jobs</div> />
<GroupedColumn tasks={activeTasks} companies={companies} projects={projects} emptyText="No active jobs" />
</div>
{showCompleted && (
<div>
<div className="card-title" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
Completed
<button onClick={() => setShowCompleted(false)} style={{ fontSize: 11, color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer' }}>
Hide
</button>
</div>
<GroupedColumn tasks={completedTasks} companies={companies} projects={projects} emptyText="No completed jobs yet" />
</div>
)}
</div> </div>
<div style={{ marginTop: 24 }}>
<OutputCharts
title="Completed By Subcontractor"
subtitle="Completed-task output by subcontractor assignee, with revisions counted by the person who submitted them."
taskPeople={subcontractorTaskPeople}
revisionPeople={subcontractorRevisionPeople}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginTop: 24 }}>
<TaskListCard
title="Deadlines"
subtitle="Upcoming due dates across active work."
tasks={upcomingDeadlineTasks}
projects={projects}
emptyMessage="No active deadlines right now."
/>
<TaskListCard
title="Assigned To You"
subtitle="Your active jobs, sorted by deadline."
tasks={assignedToMeTasks}
projects={projects}
emptyMessage="Nothing is assigned to you right now."
/>
</div>
</Layout> </Layout>
); );
} }
+74 -28
View File
@@ -32,7 +32,6 @@ export default function FileSharing() {
const { currentUser } = useAuth(); const { currentUser } = useAuth();
const [currentPath, setCurrentPath] = useState('/'); const [currentPath, setCurrentPath] = useState('/');
const [entries, setEntries] = useState([]); const [entries, setEntries] = useState([]);
const [usageBytes, setUsageBytes] = useState(0);
const [configured, setConfigured] = useState(true); const [configured, setConfigured] = useState(true);
const [parentPath, setParentPath] = useState('/'); const [parentPath, setParentPath] = useState('/');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -41,6 +40,7 @@ export default function FileSharing() {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [folderName, setFolderName] = useState(''); const [folderName, setFolderName] = useState('');
const [showFolderInput, setShowFolderInput] = useState(false); const [showFolderInput, setShowFolderInput] = useState(false);
const [movingEntry, setMovingEntry] = useState(null);
const fileInputRef = useRef(null); const fileInputRef = useRef(null);
const breadcrumbs = useMemo(() => pathParts(currentPath), [currentPath]); const breadcrumbs = useMemo(() => pathParts(currentPath), [currentPath]);
@@ -77,7 +77,6 @@ export default function FileSharing() {
const data = await apiFetch(`/api/seafile?${params.toString()}`); const data = await apiFetch(`/api/seafile?${params.toString()}`);
setConfigured(data.configured !== false); setConfigured(data.configured !== false);
setEntries(data.entries || []); setEntries(data.entries || []);
setUsageBytes(Number(data.usageBytes || 0));
setCurrentPath(data.path || '/'); setCurrentPath(data.path || '/');
setParentPath(data.parentPath || '/'); setParentPath(data.parentPath || '/');
if (data.configured === false) setError(data.error || 'Seafile is not configured yet.'); if (data.configured === false) setError(data.error || 'Seafile is not configured yet.');
@@ -224,9 +223,27 @@ export default function FileSharing() {
e.preventDefault(); e.preventDefault();
setDragging(false); setDragging(false);
if (!configured || loading || working) return; if (!configured || loading || working) return;
if (!e.dataTransfer.files?.length) return;
uploadFiles(e.dataTransfer.files); uploadFiles(e.dataTransfer.files);
}; };
const moveEntry = async (entry, targetFolderPath) => {
setWorking(`move:${entry.path}`);
setError('');
try {
await apiFetch('/api/seafile?action=move', {
method: 'POST',
body: JSON.stringify({ srcPath: entry.path, dstDir: targetFolderPath, type: entry.type }),
});
setMovingEntry(null);
await loadFiles(currentPath);
} catch (err) {
setError(err.message);
} finally {
setWorking('');
}
};
return ( return (
<Layout> <Layout>
<div className="page-header"> <div className="page-header">
@@ -234,9 +251,6 @@ export default function FileSharing() {
<div className="page-title">File Sharing</div> <div className="page-title">File Sharing</div>
<div className="page-subtitle">Shared Seafile workspace for team members and subcontractors.</div> <div className="page-subtitle">Shared Seafile workspace for team members and subcontractors.</div>
</div> </div>
<div className="page-subtitle" style={{ marginTop: 6 }}>
Used in this location: {formatBytes(usageBytes)}
</div>
</div> </div>
<section <section
@@ -337,30 +351,62 @@ export default function FileSharing() {
<h3>No files here yet</h3> <h3>No files here yet</h3>
<p>Upload files or create a folder to start this workspace.</p> <p>Upload files or create a folder to start this workspace.</p>
</div> </div>
) : entries.map(entry => ( ) : entries.map(entry => {
<div className="file-row" key={`${entry.type}:${entry.path}`}> const isMoving = movingEntry?.path === entry.path;
<span className="file-icon">{entry.type === 'dir' ? '▣' : '□'}</span> const targetFolders = entries.filter(e => e.type === 'dir' && e.path !== entry.path);
{entry.type === 'dir' ? ( return (
<button type="button" className="file-name file-name-button" onClick={() => openFolder(entry)} disabled={Boolean(working)}> <div className="file-row" key={`${entry.type}:${entry.path}`}>
{entry.name} <span className="file-icon">{entry.type === 'dir' ? '▣' : '□'}</span>
</button> {entry.type === 'dir' ? (
) : ( <button type="button" className="file-name file-name-button" onClick={() => openFolder(entry)} disabled={Boolean(working)}>
<span className="file-name">{entry.name}</span> {entry.name}
)} </button>
<span className="file-meta">{formatBytes(entry.aggregateSize ?? entry.size)}</span> ) : (
<span className="file-meta">{formatDate(entry.mtime)}</span> <span className="file-name">{entry.name}</span>
<span className="file-row-actions">
{entry.type === 'file' && (
<LoadingButton className="btn btn-outline btn-sm" loading={working === `download:${entry.path}`} disabled={Boolean(working)} loadingText="Opening..." onClick={() => downloadFile(entry)}>
Download
</LoadingButton>
)} )}
<LoadingButton className="btn btn-danger btn-sm" loading={working === `delete:${entry.path}`} disabled={Boolean(working)} loadingText="Deleting..." onClick={() => deleteEntry(entry)}> <span className="file-meta">{formatBytes(entry.aggregateSize ?? entry.size)}</span>
Delete <span className="file-meta">{formatDate(entry.mtime)}</span>
</LoadingButton> <span className="file-row-actions">
</span> {isMoving ? (
</div> <>
))} <span style={{ fontSize: 11, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>Move to:</span>
{targetFolders.length === 0 ? (
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>No folders here</span>
) : targetFolders.map(folder => (
<LoadingButton
key={folder.path}
className="btn btn-outline btn-sm"
loading={working === `move:${entry.path}`}
disabled={Boolean(working)}
loadingText="Moving..."
onClick={() => moveEntry(entry, folder.path)}
>
{folder.name}
</LoadingButton>
))}
<button type="button" className="btn btn-outline btn-sm" onClick={() => setMovingEntry(null)} disabled={Boolean(working)}></button>
</>
) : (
<>
{entry.type === 'file' && (
<LoadingButton className="btn btn-outline btn-sm" loading={working === `download:${entry.path}`} disabled={Boolean(working)} loadingText="Opening..." onClick={() => downloadFile(entry)}>
Download
</LoadingButton>
)}
{targetFolders.length > 0 && (
<button type="button" className="btn btn-outline btn-sm" disabled={Boolean(working)} onClick={() => setMovingEntry(entry)}>
Move
</button>
)}
<LoadingButton className="btn btn-danger btn-sm" loading={working === `delete:${entry.path}`} disabled={Boolean(working)} loadingText="Deleting..." onClick={() => deleteEntry(entry)}>
Delete
</LoadingButton>
</>
)}
</span>
</div>
);
})}
</div> </div>
</section> </section>
</Layout> </Layout>
+370
View File
@@ -0,0 +1,370 @@
import { useEffect, useState } from 'react';
import Layout from '../../components/Layout';
import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext';
function emptyForm() {
return {
service_name: '',
service_url: '',
username: '',
password: '',
notes: '',
};
}
function sortEntries(entries) {
return [...entries].sort((a, b) => a.service_name.localeCompare(b.service_name, undefined, { sensitivity: 'base' }));
}
function formatDate(value) {
if (!value) return 'Unknown';
return new Date(value).toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}
function normalizeUrl(value) {
const raw = String(value || '').trim();
if (!raw) return '';
if (/^https?:\/\//i.test(raw)) return raw;
return `https://${raw}`;
}
async function invokeCrypto(action, payload) {
const { data: sessionData } = await supabase.auth.getSession();
const accessToken = sessionData.session?.access_token;
const response = await fetch('/api/fourge-passwords-crypto', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
body: JSON.stringify({ action, ...payload }),
});
const data = await response.json().catch(() => null);
if (!response.ok) throw new Error(data?.error || `Vault operation failed (${response.status})`);
if (data?.error) throw new Error(data.error);
return data;
}
export default function FourgePasswords() {
const { currentUser } = useAuth();
const [entries, setEntries] = useState([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [status, setStatus] = useState('');
const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState(emptyForm());
const [revealed, setRevealed] = useState({});
async function loadEntries() {
setLoading(true);
const { data, error } = await supabase
.from('fourge_passwords')
.select('id, service_name, service_url, username, notes, created_at, updated_at, created_by')
.order('service_name', { ascending: true });
if (error) {
setStatus(`Failed to load passwords: ${error.message}`);
setEntries([]);
setLoading(false);
return;
}
setEntries(sortEntries(data || []));
setLoading(false);
setStatus('');
}
useEffect(() => {
loadEntries();
}, []);
function beginCreate() {
setEditingId('new');
setForm(emptyForm());
setStatus('');
}
function beginEdit(entry) {
setEditingId(entry.id);
setForm({
service_name: entry.service_name || '',
service_url: entry.service_url || '',
username: entry.username || '',
password: '',
notes: entry.notes || '',
});
setStatus('');
}
function cancelEdit() {
setEditingId(null);
setForm(emptyForm());
}
async function handleSave(event) {
event.preventDefault();
if (!form.service_name.trim() || !form.username.trim()) {
setStatus('Service and username are required.');
return;
}
if (editingId === 'new' && !form.password) {
setStatus('Password is required for new entries.');
return;
}
setSaving(true);
setStatus('');
try {
let encryptedPayload = null;
if (form.password) {
encryptedPayload = await invokeCrypto('encrypt', { plaintext: form.password });
}
const payload = {
service_name: form.service_name.trim(),
service_url: form.service_url.trim(),
username: form.username.trim(),
notes: form.notes.trim(),
updated_at: new Date().toISOString(),
};
if (encryptedPayload) {
payload.encrypted_password = encryptedPayload.ciphertext;
payload.password_iv = encryptedPayload.iv;
}
if (editingId === 'new') {
payload.created_by = currentUser?.id || null;
const { data, error } = await supabase
.from('fourge_passwords')
.insert(payload)
.select('id, service_name, service_url, username, notes, created_at, updated_at, created_by')
.single();
if (error) throw error;
setEntries(prev => sortEntries([...prev, data]));
setStatus('Password saved.');
} else {
const { data, error } = await supabase
.from('fourge_passwords')
.update(payload)
.eq('id', editingId)
.select('id, service_name, service_url, username, notes, created_at, updated_at, created_by')
.single();
if (error) throw error;
setEntries(prev => sortEntries(prev.map(entry => entry.id === editingId ? data : entry)));
setStatus('Password updated.');
}
cancelEdit();
} catch (error) {
setStatus(`Save failed: ${error.message}`);
} finally {
setSaving(false);
}
}
async function handleDelete(entry) {
if (!window.confirm(`Delete the password entry for "${entry.service_name}"?`)) return;
setStatus('');
const { error } = await supabase.from('fourge_passwords').delete().eq('id', entry.id);
if (error) {
setStatus(`Delete failed: ${error.message}`);
return;
}
setEntries(prev => prev.filter(item => item.id !== entry.id));
setRevealed(prev => {
const next = { ...prev };
delete next[entry.id];
return next;
});
setStatus('Password deleted.');
}
async function handleReveal(entry) {
if (revealed[entry.id]) {
setRevealed(prev => {
const next = { ...prev };
delete next[entry.id];
return next;
});
return;
}
setStatus('');
try {
const { data, error } = await supabase
.from('fourge_passwords')
.select('encrypted_password, password_iv')
.eq('id', entry.id)
.single();
if (error) throw error;
const decrypted = await invokeCrypto('decrypt', {
ciphertext: data.encrypted_password,
iv: data.password_iv,
});
setRevealed(prev => ({ ...prev, [entry.id]: decrypted.plaintext }));
} catch (error) {
setStatus(`Reveal failed: ${error.message}`);
}
}
async function handleCopy(entry) {
try {
let password = revealed[entry.id];
if (!password) {
const { data, error } = await supabase
.from('fourge_passwords')
.select('encrypted_password, password_iv')
.eq('id', entry.id)
.single();
if (error) throw error;
const decrypted = await invokeCrypto('decrypt', {
ciphertext: data.encrypted_password,
iv: data.password_iv,
});
password = decrypted.plaintext;
setRevealed(prev => ({ ...prev, [entry.id]: password }));
}
await navigator.clipboard.writeText(password);
setStatus(`Copied password for ${entry.service_name}.`);
} catch (error) {
setStatus(`Copy failed: ${error.message}`);
}
}
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">Fourge Passwords</div>
<div className="page-subtitle">Encrypted team-only vault entries for services, logins, and internal access.</div>
</div>
<button className="btn btn-primary btn-sm" onClick={beginCreate}>
+ New Password
</button>
</div>
{(editingId || status) && (
<div className="card" style={{ marginBottom: 20 }}>
{editingId && (
<form onSubmit={handleSave} style={{ display: 'grid', gap: 14 }}>
<div className="card-title">{editingId === 'new' ? 'Add Password' : 'Edit Password'}</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 12 }}>
<input className="input" placeholder="Service name" value={form.service_name} onChange={(e) => setForm(prev => ({ ...prev, service_name: e.target.value }))} />
<input className="input" placeholder="Service URL" value={form.service_url} onChange={(e) => setForm(prev => ({ ...prev, service_url: e.target.value }))} />
<input className="input" placeholder="Username / email" value={form.username} onChange={(e) => setForm(prev => ({ ...prev, username: e.target.value }))} />
<input className="input" placeholder={editingId === 'new' ? 'Password' : 'New password (leave blank to keep current)'} value={form.password} onChange={(e) => setForm(prev => ({ ...prev, password: e.target.value }))} />
</div>
<textarea className="input" rows="4" placeholder="Notes" value={form.notes} onChange={(e) => setForm(prev => ({ ...prev, notes: e.target.value }))} />
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-primary btn-sm" type="submit" disabled={saving}>
{saving ? 'Saving...' : 'Save'}
</button>
<button className="btn btn-outline btn-sm" type="button" onClick={cancelEdit}>
Cancel
</button>
</div>
</form>
)}
{status && <div style={{ marginTop: editingId ? 12 : 0, fontSize: 13, color: 'var(--text-secondary)' }}>{status}</div>}
</div>
)}
<div className="card">
{loading ? (
<div style={{ color: 'var(--text-muted)' }}>Loading passwords...</div>
) : entries.length === 0 ? (
<div className="empty-state" style={{ padding: '28px 20px' }}>
<h3 style={{ marginBottom: 8 }}>No passwords yet</h3>
<p>Add encrypted password entries for internal services and team access.</p>
</div>
) : (
<div style={{ display: 'grid', gap: 10 }}>
{entries.map(entry => (
<div
key={entry.id}
className="interactive-row"
style={{
padding: '16px',
border: '1px solid var(--border)',
borderRadius: 10,
display: 'grid',
gap: 10,
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'flex-start', flexWrap: 'wrap' }}>
<div>
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>{entry.service_name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 4 }}>
Updated {formatDate(entry.updated_at || entry.created_at)}
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button className="btn btn-outline btn-sm" onClick={() => handleReveal(entry)}>
{revealed[entry.id] ? 'Hide' : 'Reveal'}
</button>
<button className="btn btn-outline btn-sm" onClick={() => handleCopy(entry)}>
Copy
</button>
<button className="btn btn-outline btn-sm" onClick={() => beginEdit(entry)}>
Edit
</button>
<button className="btn btn-outline btn-sm" onClick={() => handleDelete(entry)}>
Delete
</button>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 12 }}>
<div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5 }}>Username</div>
<div style={{ fontSize: 14, color: 'var(--text-primary)', marginTop: 4 }}>{entry.username}</div>
</div>
<div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5 }}>Password</div>
<div style={{ fontSize: 14, color: 'var(--text-primary)', marginTop: 4, fontFamily: 'monospace' }}>
{revealed[entry.id] || '••••••••••••'}
</div>
</div>
<div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5 }}>Service URL</div>
<div style={{ fontSize: 14, color: 'var(--text-primary)', marginTop: 4, wordBreak: 'break-word' }}>
{entry.service_url ? <a href={normalizeUrl(entry.service_url)} target="_blank" rel="noreferrer" style={{ color: 'var(--accent)' }}>{entry.service_url}</a> : '—'}
</div>
</div>
</div>
{entry.notes && (
<div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5 }}>Notes</div>
<div style={{ fontSize: 13, color: 'var(--text-secondary)', marginTop: 4, whiteSpace: 'pre-wrap' }}>{entry.notes}</div>
</div>
)}
</div>
))}
</div>
)}
</div>
</Layout>
);
}
+833 -63
View File
@@ -1,15 +1,73 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout'; import Layout from '../../components/Layout';
import { supabase } from '../../lib/supabase'; import { supabase } from '../../lib/supabase';
import { readPageCache, writePageCache } from '../../lib/pageCache';
import { exportCPAPackage, generateSubcontractorPOPDF } from '../../lib/invoice';
import { sendEmail } from '../../lib/email';
const CATEGORIES = ['Software', 'Contractor', 'Advertising', 'Office', 'Travel', 'Meals', 'Equipment', 'Other'];
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' }; const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
const poStatusColor = {
draft: 'not_started',
sent: 'in_progress',
approved: 'client_approved',
ready_to_pay: 'in_progress',
paid: 'client_approved',
cancelled: 'needs_revision',
};
const poStatusLabel = {
draft: 'Draft',
sent: 'Sent',
approved: 'Approved',
ready_to_pay: 'Ready to Pay',
paid: 'Paid',
cancelled: 'Cancelled',
};
const RECEIPT_BUCKET = 'expense-receipts';
const FIELD_LABEL_STYLE = { fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', marginBottom: 4 };
const FIELD_INPUT_STYLE = { minHeight: 42, margin: 0 };
const blankExpense = () => ({
date: new Date().toISOString().slice(0, 10),
description: '',
category: 'Other',
amount: '',
notes: '',
receipt: null,
receipt_path: null,
receipt_name: null,
removeReceipt: false,
});
function getFileExt(name = '') {
const clean = String(name).split('.').pop()?.toLowerCase();
return clean && clean !== name ? clean.replace(/[^a-z0-9]/g, '') : 'bin';
}
export default function Invoices() { export default function Invoices() {
const navigate = useNavigate(); const navigate = useNavigate();
const [invoices, setInvoices] = useState([]); const location = useLocation();
const [loading, setLoading] = useState(true); const cached = readPageCache('team_invoices');
const [invoices, setInvoices] = useState(() => cached?.invoices || []);
const [loading, setLoading] = useState(() => !cached);
const [activeTab, setActiveTab] = useState(() => location.state?.tab || 'invoices');
const [filter, setFilter] = useState('all'); const [filter, setFilter] = useState('all');
const [filterCompany, setFilterCompany] = useState('');
const [exportYear, setExportYear] = useState(new Date().getFullYear());
const [exporting, setExporting] = useState(false);
const [expenses, setExpenses] = useState([]);
const [expensesLoading, setExpensesLoading] = useState(true);
const [expensesError, setExpensesError] = useState('');
const [newExpense, setNewExpense] = useState(blankExpense());
const [addingExpense, setAddingExpense] = useState(false);
const [editingExpenseId, setEditingExpenseId] = useState('');
const [expenseFilter, setExpenseFilter] = useState('all');
const [subcontractorPOs, setSubcontractorPOs] = useState([]);
const [subcontractorLoading, setSubcontractorLoading] = useState(true);
const [subcontractorError, setSubcontractorError] = useState('');
const [selectedSubcontractorPOId, setSelectedSubcontractorPOId] = useState('');
useEffect(() => { useEffect(() => {
async function load() { async function load() {
@@ -18,98 +76,810 @@ export default function Invoices() {
.select('*, company:companies(name)') .select('*, company:companies(name)')
.order('created_at', { ascending: false }); .order('created_at', { ascending: false });
setInvoices(data || []); setInvoices(data || []);
writePageCache('team_invoices', { invoices: data || [] });
setLoading(false); setLoading(false);
} }
load(); load();
}, []); }, []);
const filtered = filter === 'all' ? invoices : invoices.filter(inv => inv.status === filter); useEffect(() => {
async function loadExpenses() {
const [{ data, error }, { data: purchaseOrders, error: purchaseOrdersError }] = await Promise.all([
supabase.from('expenses').select('*').order('date', { ascending: false }),
supabase
.from('subcontractor_payments')
.select('*, profile:profiles!subcontractor_payments_profile_id_fkey(id, name, email), project:projects(id, name, company:companies(name)), items:subcontractor_po_items(*, task:tasks(id, title))')
.order('date', { ascending: false }),
]);
if (error) {
console.error('Failed to load expenses:', error);
setExpensesError(error.message || 'Failed to load expenses.');
setExpenses([]);
} else {
setExpenses(data || []);
setExpensesError('');
}
if (purchaseOrdersError) {
console.error('Failed to load subcontractor POs:', purchaseOrdersError);
setSubcontractorError(purchaseOrdersError.message || 'Failed to load subcontractor POs.');
setSubcontractorPOs([]);
} else {
setSubcontractorPOs(purchaseOrders || []);
setSubcontractorError('');
}
setExpensesLoading(false);
setSubcontractorLoading(false);
}
loadExpenses();
}, []);
const startEditExpense = (expense) => {
setEditingExpenseId(expense.id);
setNewExpense({
date: expense.date,
description: expense.description || '',
category: expense.category || 'Other',
amount: String(expense.amount ?? ''),
notes: expense.notes || '',
receipt: null,
receipt_path: expense.receipt_path || null,
receipt_name: expense.receipt_name || null,
removeReceipt: false,
});
setExpensesError('');
};
const cancelExpenseEdit = () => {
setEditingExpenseId('');
setNewExpense(blankExpense());
setExpensesError('');
};
const handleAddExpense = async (e) => {
e.preventDefault();
if (!newExpense.description.trim() || !newExpense.amount) return;
setAddingExpense(true);
setExpensesError('');
try {
const existingExpense = editingExpenseId ? expenses.find(expense => expense.id === editingExpenseId) : null;
let receiptPath = existingExpense?.receipt_path || newExpense.receipt_path || null;
let receiptName = existingExpense?.receipt_name || newExpense.receipt_name || null;
if (newExpense.removeReceipt && existingExpense?.receipt_path) {
await supabase.storage.from(RECEIPT_BUCKET).remove([existingExpense.receipt_path]);
receiptPath = null;
receiptName = null;
}
if (newExpense.receipt) {
const ext = getFileExt(newExpense.receipt.name);
receiptName = newExpense.receipt.name;
receiptPath = `${newExpense.date}/${Date.now()}-${crypto.randomUUID()}.${ext}`;
const { error: uploadError } = await supabase.storage.from(RECEIPT_BUCKET).upload(receiptPath, newExpense.receipt, { upsert: false });
if (uploadError) throw uploadError;
if (existingExpense?.receipt_path) {
await supabase.storage.from(RECEIPT_BUCKET).remove([existingExpense.receipt_path]);
}
}
const payload = {
date: newExpense.date,
description: newExpense.description.trim(),
category: newExpense.category,
amount: Number(newExpense.amount),
notes: newExpense.notes.trim(),
receipt_path: receiptPath,
receipt_name: receiptName,
};
const query = editingExpenseId
? supabase.from('expenses').update(payload).eq('id', editingExpenseId)
: supabase.from('expenses').insert(payload);
const { data, error } = await query.select().single();
if (error) {
if (newExpense.receipt && receiptPath) {
await supabase.storage.from(RECEIPT_BUCKET).remove([receiptPath]);
}
throw error;
}
if (data) {
setExpenses(prev => editingExpenseId
? prev.map(expense => expense.id === editingExpenseId ? data : expense)
: [data, ...prev]
);
setNewExpense(blankExpense());
setEditingExpenseId('');
}
} catch (error) {
console.error(`Failed to ${editingExpenseId ? 'update' : 'add'} expense:`, error);
setExpensesError(error.message || `Failed to ${editingExpenseId ? 'update' : 'add'} expense.`);
alert(`Failed to ${editingExpenseId ? 'update' : 'add'} expense: ${error.message || 'unknown error'}`);
}
setAddingExpense(false);
};
const handleDeleteExpense = async (id) => {
if (!window.confirm('Delete this expense?')) return;
const expense = expenses.find(e => e.id === id);
if (expense?.receipt_path) {
await supabase.storage.from(RECEIPT_BUCKET).remove([expense.receipt_path]);
}
await supabase.from('expenses').delete().eq('id', id);
setExpenses(prev => prev.filter(e => e.id !== id));
};
const handleViewReceipt = async (expense) => {
if (!expense.receipt_path) return;
const { data, error } = await supabase.storage.from(RECEIPT_BUCKET).createSignedUrl(expense.receipt_path, 3600, {
download: expense.receipt_name || undefined,
});
if (error || !data?.signedUrl) {
const message = error?.message || 'Failed to open receipt.';
setExpensesError(message);
alert(message);
return;
}
window.open(data.signedUrl, '_blank', 'noopener,noreferrer');
};
const updateSubcontractorPO = async (po, updates, errorLabel = 'Failed to update PO') => {
const { data, error } = await supabase
.from('subcontractor_payments')
.update(updates)
.eq('id', po.id)
.select('*, profile:profiles!subcontractor_payments_profile_id_fkey(id, name, email), project:projects(id, name, company:companies(name)), items:subcontractor_po_items(*, task:tasks(id, title))')
.single();
if (error) {
alert(`${errorLabel}: ${error.message}`);
return null;
}
if (data) {
setSubcontractorPOs(prev => prev.map(row => row.id === po.id ? data : row));
setSelectedSubcontractorPOId(current => current === po.id ? data.id : current);
}
return data;
};
const handleSendSubcontractorPO = async (po) => {
const sentPO = await updateSubcontractorPO(po, { status: 'sent', sent_at: new Date().toISOString() }, 'Failed to send PO');
if (!sentPO?.profile?.email) return;
try {
await sendEmail('subcontractor_po_sent', sentPO.profile.email, {
poNumber: sentPO.po_number || 'Purchase Order',
subcontractorName: sentPO.profile?.name || 'there',
projectName: sentPO.project?.name || 'Subcontractor Work',
companyName: sentPO.project?.company?.name || 'Fourge Branding',
amount: `$${Number(sentPO.amount).toFixed(2)}`,
dueDate: sentPO.due_date ? new Date(sentPO.due_date).toLocaleDateString() : 'Not set',
terms: sentPO.terms || 'Net 15',
scope: sentPO.items?.length
? sentPO.items.map(item => `${item.description}$${Number(item.amount).toFixed(2)}`).join('\n')
: sentPO.description,
portalUrl: 'https://portal.fourgebranding.com/my-purchase-orders',
});
} catch (emailError) {
console.error('Failed to email subcontractor PO:', emailError);
alert(`PO was marked sent, but the email failed: ${emailError.message || 'unknown error'}`);
}
};
const handleReadyToPaySubcontractorPO = (po) => {
updateSubcontractorPO(po, { status: 'ready_to_pay' }, 'Failed to mark ready to pay');
};
const handleMarkSubcontractorPaid = async (po) => {
const paidAt = new Date().toISOString().slice(0, 10);
updateSubcontractorPO(po, { status: 'paid', paid_at: paidAt }, 'Failed to mark as paid');
};
const handleReopenSubcontractorPO = async (po) => {
updateSubcontractorPO(po, { status: 'draft', paid_at: null, cancelled_at: null }, 'Failed to reopen PO');
};
const handleCancelSubcontractorPO = async (po) => {
if (!window.confirm(`Cancel ${po.po_number || 'this PO'}?`)) return;
updateSubcontractorPO(po, { status: 'cancelled', cancelled_at: new Date().toISOString() }, 'Failed to cancel PO');
};
const handleDeleteSubcontractorPO = async (id) => {
if (!window.confirm('Delete this subcontractor PO?')) return;
await supabase.from('subcontractor_payments').delete().eq('id', id);
setSubcontractorPOs(prev => prev.filter(po => po.id !== id));
setSelectedSubcontractorPOId(current => current === id ? '' : current);
};
const handleDownloadSubcontractorPO = async (po) => {
try {
await generateSubcontractorPOPDF(po);
} catch (error) {
console.error('Failed to download subcontractor PO:', error);
alert(`Failed to download PO: ${error.message || 'unknown error'}`);
}
};
const handleCPAExport = async () => {
setExporting(true);
try {
const yearInvoices = invoices.filter(inv =>
inv.status === 'paid' && new Date(inv.invoice_date).getFullYear() === exportYear
);
const yearExpenses = expenses.filter(exp =>
new Date(exp.date).getFullYear() === exportYear
);
const yearSubcontractorExpenses = subcontractorPOs
.filter(po => po.status === 'paid' && new Date(po.paid_at || po.date).getFullYear() === exportYear)
.map(po => ({
date: po.paid_at || po.date,
category: 'Contractor',
description: `Subcontractor: ${po.profile?.name || 'External'}${po.items?.length ? po.items.map(item => item.description).join('; ') : po.description}`,
amount: po.amount,
notes: [po.po_number, po.project?.name, po.notes || po.profile?.email || ''].filter(Boolean).join(' · '),
}));
const exportExpenses = [...yearExpenses, ...yearSubcontractorExpenses];
if (!yearInvoices.length && !exportExpenses.length) {
alert(`No data found for ${exportYear}.`);
return;
}
const { data: allItems } = yearInvoices.length ? await supabase
.from('invoice_items')
.select('invoice_id, description')
.in('invoice_id', yearInvoices.map(i => i.id)) : { data: [] };
const itemsByInvoice = (allItems || []).reduce((acc, item) => {
(acc[item.invoice_id] = acc[item.invoice_id] || []).push(item);
return acc;
}, {});
await exportCPAPackage(yearInvoices, itemsByInvoice, exportExpenses, exportYear);
} finally {
setExporting(false);
}
};
const paidYears = [...new Set(
invoices.filter(i => i.status === 'paid').map(i => new Date(i.invoice_date).getFullYear())
)].sort((a, b) => b - a);
const companyNames = [...new Set(invoices.map(inv => inv.company?.name).filter(Boolean))].sort();
const filtered = invoices.filter(inv => {
if (filter !== 'all' && inv.status !== filter) return false;
if (filterCompany && inv.company?.name !== filterCompany) return false;
return true;
});
const totals = { const totals = {
all: invoices.reduce((s, i) => s + Number(i.total), 0), all: invoices.reduce((s, i) => s + Number(i.total), 0),
draft: invoices.filter(i => i.status === 'draft').reduce((s, i) => s + Number(i.total), 0), draft: invoices.filter(i => i.status === 'draft').reduce((s, i) => s + Number(i.total), 0),
sent: invoices.filter(i => i.status === 'sent').reduce((s, i) => s + Number(i.total), 0), sent: invoices.filter(i => i.status === 'sent').reduce((s, i) => s + Number(i.total), 0),
paid: invoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total), 0), paid: invoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total), 0),
netReceived: invoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total) - Number(i.stripe_fee || 0), 0),
}; };
const filteredExpenses = expenseFilter === 'all'
? expenses
: expenses.filter(e => e.category === expenseFilter);
const paidSubcontractorPOs = subcontractorPOs.filter(po => po.status === 'paid');
const payableSubcontractorPOs = subcontractorPOs.filter(po => ['approved', 'ready_to_pay'].includes(po.status));
const selectedSubcontractorPO = subcontractorPOs.find(po => po.id === selectedSubcontractorPOId);
const totalPaidSubcontractors = paidSubcontractorPOs.reduce((s, po) => s + Number(po.amount), 0);
const totalPayableSubcontractors = payableSubcontractorPOs.reduce((s, po) => s + Number(po.amount), 0);
const totalExpenses = filteredExpenses.reduce((s, e) => s + Number(e.amount), 0);
const currentYear = new Date().getFullYear();
const yearExpenses = expenses.filter(e => new Date(e.date).getFullYear() === currentYear);
const currentYearExpenseTotal = yearExpenses.reduce((s, e) => s + Number(e.amount), 0);
const currentYearPaidSubcontractors = paidSubcontractorPOs
.filter(po => new Date(po.paid_at || po.date).getFullYear() === currentYear)
.reduce((s, po) => s + Number(po.amount), 0);
const currentYearTotalExpenses = currentYearExpenseTotal + currentYearPaidSubcontractors;
const revenue = totals.paid;
const profit = totals.netReceived - expenses.reduce((s, e) => s + Number(e.amount), 0) - totalPaidSubcontractors;
return ( return (
<Layout> <Layout>
<div className="page-header"> <div className="page-header">
<div> <div>
<div className="page-title">Invoices</div> <div className="page-title">Invoices & Expenses</div>
<div className="page-subtitle">{invoices.length} invoice{invoices.length !== 1 ? 's' : ''} total</div> <div className="page-subtitle">{invoices.length} invoice{invoices.length !== 1 ? 's' : ''} · {expenses.length} expense{expenses.length !== 1 ? 's' : ''} · {subcontractorPOs.length} subcontractor PO{subcontractorPOs.length !== 1 ? 's' : ''}</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{paidYears.length > 0 && (
<select
value={exportYear}
onChange={e => setExportYear(Number(e.target.value))}
className="btn btn-outline btn-sm"
style={{ cursor: 'pointer' }}
>
{paidYears.map(y => (
<option key={y} value={y}>{y}</option>
))}
</select>
)}
<button className="btn btn-outline btn-sm" onClick={handleCPAExport} disabled={exporting}>
{exporting ? 'Exporting…' : 'Export for CPA'}
</button>
</div> </div>
<button className="btn btn-primary btn-sm" onClick={() => navigate('/invoices/new')}>+ New Invoice</button>
</div> </div>
<div className="stats-grid" style={{ marginBottom: 24 }}> <div className="stats-grid" style={{ marginBottom: 18 }}>
{[ {[
{ label: 'Revenue', value: revenue, detail: 'paid invoices' },
{ label: 'Profit', value: profit, detail: 'net minus expenses' },
{ label: 'Outstanding', value: totals.sent, count: invoices.filter(i => i.status === 'sent').length }, { label: 'Outstanding', value: totals.sent, count: invoices.filter(i => i.status === 'sent').length },
{ label: 'Paid', value: totals.paid, count: invoices.filter(i => i.status === 'paid').length }, { label: 'Paid', value: totals.paid, count: invoices.filter(i => i.status === 'paid').length },
{ label: 'Draft', value: totals.draft, count: invoices.filter(i => i.status === 'draft').length }, { label: 'Net Received', value: totals.netReceived, detail: 'after Stripe fees' },
{ label: 'Total Billed', value: totals.all, count: invoices.length }, ].map(({ label, value, count, detail }) => (
].map(({ label, value, count }) => ( <div key={label} className={`stat-card${label === 'Revenue' ? ' stat-card-highlight' : ''}`}>
<div key={label} className="stat-card"> <div className="stat-value" style={{ fontSize: 22, color: label === 'Profit' && value < 0 ? 'var(--danger)' : undefined }}>${value.toFixed(2)}</div>
<div className="stat-value" style={{ fontSize: 22 }}>${value.toFixed(2)}</div> <div className="stat-label">{count !== undefined ? `${label} · ${count} invoice${count !== 1 ? 's' : ''}` : `${label} · ${detail}`}</div>
<div className="stat-label">{label} · {count} invoice{count !== 1 ? 's' : ''}</div>
</div> </div>
))} ))}
</div> </div>
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}> <div className="stats-grid" style={{ marginBottom: 24 }}>
{['all', 'draft', 'sent', 'paid'].map(s => ( <div className="stat-card">
<button <div className="stat-value" style={{ fontSize: 22 }}>${currentYearTotalExpenses.toFixed(2)}</div>
key={s} <div className="stat-label">Expenses This Year · includes paid subcontractors</div>
onClick={() => setFilter(s)} </div>
className={`btn btn-sm ${filter === s ? 'btn-primary' : 'btn-outline'}`} <div className="stat-card">
style={{ textTransform: 'capitalize' }} <div className="stat-value" style={{ fontSize: 22 }}>${totalExpenses.toFixed(2)}</div>
> <div className="stat-label">Filtered Expenses · {filteredExpenses.length} item{filteredExpenses.length !== 1 ? 's' : ''}</div>
{s} </div>
</button> <div className="stat-card">
))} <div className="stat-value" style={{ fontSize: 22 }}>${totalPayableSubcontractors.toFixed(2)}</div>
<div className="stat-label">Subcontractors Payable · {payableSubcontractorPOs.length} approved/ready</div>
</div>
</div> </div>
{loading ? ( <div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<p style={{ color: 'var(--text-muted)' }}>Loading...</p> <div className="card-title" style={{ marginBottom: 0, display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
) : filtered.length === 0 ? ( {[
<div className="empty-state"> { id: 'invoices', label: 'Invoices' },
<h3>No invoices</h3> { id: 'subcontractor-po', label: 'Subcontractor PO' },
<p>Create your first invoice to get started.</p> { id: 'expenses', label: 'Expenses' },
<button className="btn btn-primary" style={{ marginTop: 16 }} onClick={() => navigate('/invoices/new')}>+ New Invoice</button> ].map((tab, index) => (
<span key={tab.id} style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
{index > 0 && <span style={{ color: 'var(--text-muted)' }}>|</span>}
<button
type="button"
onClick={() => setActiveTab(tab.id)}
style={{
background: 'none',
border: 'none',
padding: 0,
margin: 0,
cursor: 'pointer',
color: activeTab === tab.id ? 'var(--text-primary)' : 'var(--text-muted)',
font: 'inherit',
textTransform: 'inherit',
letterSpacing: 'inherit',
}}
>
{tab.label}
</button>
</span>
))}
</div> </div>
) : ( {activeTab === 'invoices' && (
<div className="table-wrapper"> <button className="btn btn-primary btn-sm" onClick={() => navigate('/invoices/new')}>+ New Invoice</button>
<table> )}
<thead> {activeTab === 'subcontractor-po' && (
<tr> <button className="btn btn-primary btn-sm" onClick={() => navigate('/subcontractor-pos/new')}>+ New PO</button>
<th>Invoice #</th> )}
<th>Company</th> </div>
<th>Date</th>
<th>Due</th> {activeTab === 'invoices' && (
<th>Status</th> <div>
<th>Total</th>
</tr> {/* ── Invoices ── */}
</thead> <div>
<tbody> <div className="card request-toolbar-card" style={{ marginBottom: 16 }}>
{filtered.map(inv => ( <div className="request-toolbar-section">
<tr key={inv.id} onClick={() => navigate(`/invoices/${inv.id}`)} style={{ cursor: 'pointer' }}> <div className="card-title" style={{ marginBottom: 10 }}>Filter by Status</div>
<td style={{ fontWeight: 600 }}>{inv.invoice_number}</td> <div className="request-toolbar-actions">
<td> {['all', 'draft', 'sent', 'paid'].map(s => (
<div style={{ fontWeight: 600 }}>{inv.company?.name}</div> <button
</td> key={s}
<td>{new Date(inv.invoice_date).toLocaleDateString()}</td> onClick={() => setFilter(s)}
<td> className={`btn btn-sm ${filter === s ? 'btn-primary' : 'btn-outline'}`}
<span style={{ color: inv.status !== 'paid' && new Date(inv.due_date) < new Date() ? 'var(--danger)' : 'inherit' }}> style={{ textTransform: 'capitalize' }}
{new Date(inv.due_date).toLocaleDateString()} >
</span> {s}
</td> </button>
<td><span className={`badge badge-${statusColor[inv.status]}`} style={{ textTransform: 'capitalize' }}>{inv.status}</span></td> ))}
<td style={{ fontWeight: 700 }}>${Number(inv.total).toFixed(2)}</td> </div>
</tr> </div>
{companyNames.length > 0 && (
<div className="request-toolbar-grid">
<div className="request-toolbar-section">
<div className="card-title" style={{ marginBottom: 10 }}>Filter by Company</div>
<div className="request-filter-row">
<button
onClick={() => setFilterCompany('')}
className={`btn btn-sm ${!filterCompany ? 'btn-primary' : 'btn-outline'}`}
>
All
</button>
{companyNames.map(name => (
<button
key={name}
onClick={() => setFilterCompany(current => current === name ? '' : name)}
className={`btn btn-sm ${filterCompany === name ? 'btn-primary' : 'btn-outline'}`}
>
{name}
</button>
))}
</div>
</div>
</div>
)}
</div>
{loading ? (
<p style={{ color: 'var(--text-muted)' }}>Loading...</p>
) : filtered.length === 0 ? (
<div className="empty-state">
<h3>No invoices</h3>
<p>Create your first invoice to get started.</p>
<button className="btn btn-primary" style={{ marginTop: 16 }} onClick={() => navigate('/invoices/new')}>+ New Invoice</button>
</div>
) : (
<div className="table-wrapper">
<table>
<thead>
<tr>
<th>Invoice #</th>
<th>Bill To</th>
<th>Date</th>
<th>Due</th>
<th>Status</th>
<th>Total</th>
</tr>
</thead>
<tbody>
{filtered.map(inv => (
<tr key={inv.id} onClick={() => navigate(`/invoices/${inv.id}`)} style={{ cursor: 'pointer' }}>
<td style={{ fontWeight: 600 }}>{inv.invoice_number}</td>
<td style={{ fontWeight: 600 }}>{inv.bill_to || inv.company?.name}</td>
<td>{new Date(inv.invoice_date).toLocaleDateString()}</td>
<td>
<span style={{ color: inv.status !== 'paid' && new Date(inv.due_date) < new Date() ? 'var(--danger)' : 'inherit' }}>
{new Date(inv.due_date).toLocaleDateString()}
</span>
</td>
<td><span className={`badge badge-${statusColor[inv.status]}`} style={{ textTransform: 'capitalize' }}>{inv.status}</span></td>
<td style={{ fontWeight: 700 }}>${Number(inv.total).toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)}
{activeTab === 'expenses' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 360px', gap: 24, alignItems: 'start' }}>
<div>
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12 }}>
<div className="card-title" style={{ marginBottom: 0 }}>Saved Expenses</div>
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--accent)' }}>${totalExpenses.toFixed(2)}</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
<button
onClick={() => setExpenseFilter('all')}
className={`btn btn-sm ${expenseFilter === 'all' ? 'btn-primary' : 'btn-outline'}`}
>
All
</button>
{CATEGORIES.filter(c => expenses.some(e => e.category === c)).map(c => (
<button
key={c}
onClick={() => setExpenseFilter(f => f === c ? 'all' : c)}
className={`btn btn-sm ${expenseFilter === c ? 'btn-primary' : 'btn-outline'}`}
>
{c}
</button>
))} ))}
</tbody> </div>
</table>
{expensesLoading ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Loading...</p>
) : expensesError ? (
<p style={{ color: 'var(--danger)', fontSize: 13 }}>{expensesError}</p>
) : filteredExpenses.length === 0 ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>No expenses yet.</p>
) : (
<div className="table-wrapper" style={{ marginTop: 0 }}>
<table>
<thead>
<tr>
<th>Date</th>
<th>Description</th>
<th>Category</th>
<th>Notes</th>
<th style={{ textAlign: 'right' }}>Amount</th>
<th style={{ width: 140, textAlign: 'right' }}>Action</th>
</tr>
</thead>
<tbody>
{filteredExpenses.map(exp => (
<tr key={exp.id}>
<td>{new Date(exp.date).toLocaleDateString()}</td>
<td style={{ fontWeight: 600 }}>{exp.description}</td>
<td>{exp.category}</td>
<td style={{ color: 'var(--text-muted)' }}>
{exp.notes || exp.receipt_path ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<span>{exp.notes || '—'}</span>
{exp.receipt_path && (
<button
className="btn btn-outline btn-sm"
style={{ width: 'fit-content' }}
onClick={() => handleViewReceipt(exp)}
>
View Receipt
</button>
)}
</div>
) : '—'}
</td>
<td style={{ textAlign: 'right', fontWeight: 700 }}>${Number(exp.amount).toFixed(2)}</td>
<td style={{ textAlign: 'right' }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 6 }}>
<button
className="btn btn-outline btn-sm"
onClick={() => startEditExpense(exp)}
>
Edit
</button>
<button
className="btn btn-danger btn-sm"
onClick={() => handleDeleteExpense(exp.id)}
>
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div> </div>
{/* ── Expenses ── */}
<div>
<div className="card" style={{ marginBottom: 16 }}>
<div className="card-title">{editingExpenseId ? 'Edit Expense' : 'Add Expense'}</div>
<form onSubmit={handleAddExpense} style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<div>
<label style={FIELD_LABEL_STYLE}>Date</label>
<input
type="date"
className="input"
value={newExpense.date}
onChange={e => setNewExpense(p => ({ ...p, date: e.target.value }))}
style={FIELD_INPUT_STYLE}
required
/>
</div>
<div>
<label style={FIELD_LABEL_STYLE}>Amount (USD)</label>
<input
type="number"
step="0.01"
min="0"
className="input"
placeholder="0.00"
value={newExpense.amount}
onChange={e => setNewExpense(p => ({ ...p, amount: e.target.value }))}
style={{ ...FIELD_INPUT_STYLE, minHeight: 38, borderRadius: 10 }}
required
/>
</div>
</div>
<div>
<label style={FIELD_LABEL_STYLE}>Description</label>
<input
type="text"
className="input"
placeholder="What was this for?"
value={newExpense.description}
onChange={e => setNewExpense(p => ({ ...p, description: e.target.value }))}
required
/>
</div>
<div>
<label style={FIELD_LABEL_STYLE}>Category</label>
<select
className="input"
value={newExpense.category}
onChange={e => setNewExpense(p => ({ ...p, category: e.target.value }))}
style={FIELD_INPUT_STYLE}
>
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div>
<label style={FIELD_LABEL_STYLE}>Notes (optional)</label>
<input
type="text"
className="input"
placeholder="Any extra details"
value={newExpense.notes}
onChange={e => setNewExpense(p => ({ ...p, notes: e.target.value }))}
/>
</div>
<div>
<label style={FIELD_LABEL_STYLE}>Receipt / Photo (optional)</label>
<input
type="file"
className="input"
accept="image/*,.pdf"
style={{ ...FIELD_INPUT_STYLE, padding: '9px 12px' }}
onChange={e => setNewExpense(p => ({ ...p, receipt: e.target.files?.[0] || null, removeReceipt: false }))}
/>
{!newExpense.receipt && newExpense.receipt_path && (
<div style={{ marginTop: 6, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{newExpense.receipt_name || 'Existing receipt attached'}</span>
<button
type="button"
className="btn btn-outline btn-sm"
onClick={() => handleViewReceipt(newExpense)}
>
View Receipt
</button>
<button
type="button"
className="btn btn-outline btn-sm"
style={{ color: 'var(--danger)' }}
onClick={() => setNewExpense(p => ({ ...p, removeReceipt: true, receipt_path: null, receipt_name: null }))}
>
Remove Receipt
</button>
</div>
)}
{newExpense.receipt && (
<div style={{ marginTop: 6, fontSize: 12, color: 'var(--text-muted)' }}>
{newExpense.receipt.name}
</div>
)}
</div>
<div className="action-buttons" style={{ marginTop: 4 }}>
<button className="btn btn-primary btn-sm" type="submit" disabled={addingExpense}>
{addingExpense ? (editingExpenseId ? 'Saving…' : 'Adding…') : (editingExpenseId ? 'Save Changes' : '+ Add Expense')}
</button>
{editingExpenseId && (
<button className="btn btn-outline btn-sm" type="button" onClick={cancelExpenseEdit}>
Cancel
</button>
)}
</div>
{expensesError && (
<div style={{ fontSize: 12, color: 'var(--danger)' }}>
{expensesError}
</div>
)}
</form>
</div>
</div>
</div>
)}
{activeTab === 'subcontractor-po' && (
<div>
<div>
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12 }}>
<div className="card-title" style={{ marginBottom: 0 }}>POs & Payments</div>
<div style={{ display: 'flex', gap: 12, fontSize: 13, fontWeight: 700 }}>
<span style={{ color: 'var(--accent)' }}>${totalPayableSubcontractors.toFixed(2)} payable</span>
<span>${totalPaidSubcontractors.toFixed(2)} paid</span>
</div>
</div>
{subcontractorLoading ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Loading...</p>
) : subcontractorError ? (
<p style={{ color: 'var(--danger)', fontSize: 13 }}>{subcontractorError}</p>
) : subcontractorPOs.length === 0 ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>No subcontractor POs yet.</p>
) : (
<>
{selectedSubcontractorPO && (
<div style={{ marginBottom: 16, padding: 16, background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 8 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 16, alignItems: 'flex-start', marginBottom: 12 }}>
<div>
<div style={{ fontSize: 18, fontWeight: 800 }}>{selectedSubcontractorPO.po_number || 'Purchase Order'}</div>
<div style={{ color: 'var(--text-muted)', fontSize: 13 }}>
{selectedSubcontractorPO.profile?.name || 'External'} · {selectedSubcontractorPO.project?.name || 'No project'}
</div>
</div>
<button className="btn btn-outline btn-sm" onClick={() => setSelectedSubcontractorPOId('')}>Close</button>
</div>
<div className="detail-grid" style={{ marginBottom: 12 }}>
<div className="detail-item"><label>Status</label><p><span className={`badge badge-${poStatusColor[selectedSubcontractorPO.status] || 'not_started'}`}>{poStatusLabel[selectedSubcontractorPO.status] || selectedSubcontractorPO.status}</span></p></div>
<div className="detail-item"><label>Amount</label><p>${Number(selectedSubcontractorPO.amount).toFixed(2)}</p></div>
<div className="detail-item"><label>Date</label><p>{new Date(selectedSubcontractorPO.date).toLocaleDateString()}</p></div>
<div className="detail-item"><label>Due</label><p>{selectedSubcontractorPO.due_date ? new Date(selectedSubcontractorPO.due_date).toLocaleDateString() : '—'}</p></div>
</div>
{selectedSubcontractorPO.items?.length > 0 && (
<div style={{ display: 'grid', gap: 6, marginBottom: 12 }}>
{selectedSubcontractorPO.items
.slice()
.sort((a, b) => Number(a.sort_order || 0) - Number(b.sort_order || 0))
.map(item => (
<div key={item.id} style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: 13 }}>
<span>{item.description || item.task?.title}</span>
<strong>${Number(item.amount).toFixed(2)}</strong>
</div>
))}
</div>
)}
{selectedSubcontractorPO.notes && (
<p style={{ color: 'var(--text-secondary)', fontSize: 13, whiteSpace: 'pre-wrap' }}>{selectedSubcontractorPO.notes}</p>
)}
<div className="action-buttons" style={{ marginTop: 12 }}>
{selectedSubcontractorPO.status === 'draft' && <button className="btn btn-primary btn-sm" onClick={() => handleSendSubcontractorPO(selectedSubcontractorPO)}>Finalize & Send</button>}
{['sent', 'approved'].includes(selectedSubcontractorPO.status) && <button className="btn btn-outline btn-sm" onClick={() => handleReadyToPaySubcontractorPO(selectedSubcontractorPO)}>Mark Ready</button>}
{selectedSubcontractorPO.status === 'ready_to_pay' && <button className="btn btn-success btn-sm" onClick={() => handleMarkSubcontractorPaid(selectedSubcontractorPO)}>Mark Paid</button>}
{selectedSubcontractorPO.status === 'paid' && <button className="btn btn-outline btn-sm" onClick={() => handleReopenSubcontractorPO(selectedSubcontractorPO)}>Reopen</button>}
<button className="btn btn-outline btn-sm" onClick={() => handleDownloadSubcontractorPO(selectedSubcontractorPO)}>Download</button>
{!['paid', 'cancelled'].includes(selectedSubcontractorPO.status) && <button className="btn btn-outline btn-sm" onClick={() => handleCancelSubcontractorPO(selectedSubcontractorPO)}>Cancel</button>}
<button className="btn btn-danger btn-sm" onClick={() => handleDeleteSubcontractorPO(selectedSubcontractorPO.id)}>Delete</button>
</div>
</div>
)}
<div className="table-wrapper" style={{ marginTop: 0 }}>
<table>
<thead>
<tr>
<th>PO #</th>
<th>Subcontractor</th>
<th>Date</th>
<th>Status</th>
<th style={{ textAlign: 'right' }}>Amount</th>
</tr>
</thead>
<tbody>
{subcontractorPOs.map(po => (
<tr key={po.id} onClick={() => navigate(`/subcontractor-pos/${po.id}`)} style={{ cursor: 'pointer' }}>
<td>
<div style={{ fontWeight: 700 }}>{po.po_number || 'PO'}</div>
</td>
<td>
<div style={{ fontWeight: 600 }}>{po.profile?.name || 'External'}</div>
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>{po.profile?.email || '—'}</div>
</td>
<td>
<div>{new Date(po.paid_at || po.date).toLocaleDateString()}</div>
{po.due_date && <div style={{ color: 'var(--text-muted)', fontSize: 12 }}>Due {new Date(po.due_date).toLocaleDateString()}</div>}
</td>
<td>
<span className={`badge badge-${poStatusColor[po.status] || 'not_started'}`} style={{ textTransform: 'capitalize' }}>
{poStatusLabel[po.status] || po.status}
</span>
</td>
<td style={{ textAlign: 'right', fontWeight: 700 }}>${Number(po.amount).toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</div>
</div>
</div>
)} )}
</Layout> </Layout>
); );
+193
View File
@@ -0,0 +1,193 @@
import { useEffect, useState } from 'react';
import Layout from '../../components/Layout';
import LoadingButton from '../../components/LoadingButton';
import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext';
function emptyForm() {
return {
title: '',
attendees: '',
meeting_at: new Date().toISOString().slice(0, 16),
notes: '',
};
}
function formatMeetingDate(value) {
if (!value) return 'Unknown date';
return new Date(value).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}
export default function MeetingNotes() {
const { currentUser } = useAuth();
const [notes, setNotes] = useState([]);
const [form, setForm] = useState(emptyForm());
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [deletingId, setDeletingId] = useState('');
const [status, setStatus] = useState('');
useEffect(() => {
let isMounted = true;
async function loadInitialNotes() {
const { data, error } = await supabase
.from('meeting_notes')
.select('*')
.order('meeting_at', { ascending: false })
.order('created_at', { ascending: false });
if (!isMounted) return;
if (error) {
setStatus(`Failed to load meeting notes: ${error.message}`);
setNotes([]);
} else {
setNotes(data || []);
setStatus('');
}
setLoading(false);
}
loadInitialNotes();
return () => {
isMounted = false;
};
}, []);
const handleSubmit = async (e) => {
e.preventDefault();
if (!form.title.trim() || !form.notes.trim()) {
setStatus('Meeting title and notes are required.');
return;
}
setSaving(true);
const payload = {
title: form.title.trim(),
attendees: form.attendees.trim(),
meeting_at: form.meeting_at ? new Date(form.meeting_at).toISOString() : new Date().toISOString(),
notes: form.notes.trim(),
created_by: currentUser?.id || null,
updated_at: new Date().toISOString(),
};
const { data, error } = await supabase
.from('meeting_notes')
.insert(payload)
.select('*')
.single();
setSaving(false);
if (error) {
setStatus(`Failed to save note: ${error.message}`);
return;
}
setNotes(prev => [data, ...prev]);
setForm(emptyForm());
setStatus('Meeting note added.');
};
const handleDelete = async (entry) => {
if (!window.confirm(`Delete meeting note "${entry.title}"?`)) return;
setDeletingId(entry.id);
const { error } = await supabase.from('meeting_notes').delete().eq('id', entry.id);
setDeletingId('');
if (error) {
setStatus(`Failed to delete note: ${error.message}`);
return;
}
setNotes(prev => prev.filter(note => note.id !== entry.id));
setStatus('Meeting note deleted.');
};
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">Meeting Notes</div>
<div className="page-subtitle">Internal team timeline for meeting recaps, decisions, and follow-ups.</div>
</div>
</div>
<div style={{ display: 'grid', gap: 18 }}>
<section className="card">
<div className="card-title">Add Note</div>
<form onSubmit={handleSubmit}>
<div className="grid-2">
<div className="form-group">
<label>Meeting Title</label>
<input type="text" value={form.title} onChange={(e) => setForm(prev => ({ ...prev, title: e.target.value }))} placeholder="Weekly team sync" />
</div>
<div className="form-group">
<label>Meeting Date</label>
<input type="datetime-local" value={form.meeting_at} onChange={(e) => setForm(prev => ({ ...prev, meeting_at: e.target.value }))} />
</div>
</div>
<div className="form-group">
<label>Attendees</label>
<input type="text" value={form.attendees} onChange={(e) => setForm(prev => ({ ...prev, attendees: e.target.value }))} placeholder="Team, client, subcontractor" />
</div>
<div className="form-group" style={{ marginBottom: 12 }}>
<label>Notes</label>
<textarea value={form.notes} onChange={(e) => setForm(prev => ({ ...prev, notes: e.target.value }))} placeholder="Key decisions, next steps, blockers, and follow-up items..." style={{ minHeight: 180 }} />
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>{status || 'Newest notes appear first in the timeline.'}</div>
<LoadingButton className="btn btn-primary" loading={saving} loadingText="Saving...">Save Note</LoadingButton>
</div>
</form>
</section>
<section className="card">
<div className="card-title">Timeline</div>
{loading ? (
<div style={{ color: 'var(--text-muted)' }}>Loading meeting notes...</div>
) : notes.length === 0 ? (
<div className="empty-state" style={{ padding: '36px 12px' }}>
<h3>No meeting notes yet</h3>
<p>Add the first entry to start the internal timeline.</p>
</div>
) : (
<div className="meeting-timeline">
{notes.map((entry) => (
<article key={entry.id} className="meeting-note-card">
<div className="meeting-note-marker" aria-hidden="true" />
<div className="meeting-note-content">
<div className="meeting-note-header">
<div>
<div className="meeting-note-title">{entry.title}</div>
<div className="meeting-note-meta">
<span>{formatMeetingDate(entry.meeting_at)}</span>
{entry.attendees ? <span>Attendees: {entry.attendees}</span> : null}
</div>
</div>
<LoadingButton
className="btn btn-danger btn-sm"
loading={deletingId === entry.id}
disabled={Boolean(deletingId)}
loadingText="Deleting..."
onClick={() => handleDelete(entry)}
>
Delete
</LoadingButton>
</div>
<div className="meeting-note-body">{entry.notes}</div>
</div>
</article>
))}
</div>
)}
</section>
</div>
</Layout>
);
}
-58
View File
@@ -1,58 +0,0 @@
import { Link } from 'react-router-dom';
import Layout from '../../components/Layout';
import StatusBadge from '../../components/StatusBadge';
import { mockProjects, mockTasks } from '../../data/mockData';
export default function Projects() {
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">Projects</div>
<div className="page-subtitle">All client engagements and their tasks.</div>
</div>
</div>
<div className="table-wrapper">
<table>
<thead>
<tr>
<th>Project</th>
<th>Client</th>
<th>Company</th>
<th>Jobs</th>
<th>Status</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{mockProjects.map(project => {
const tasks = mockTasks.filter(t => t.projectId === project.id);
const activeTasks = tasks.filter(t => t.status !== 'approved');
return (
<tr key={project.id}>
<td>
<Link to={`/projects/${project.id}`} className="table-link">{project.name}</Link>
</td>
<td>
{project.clientName}
{!project.clientId && (
<span className="badge badge-not_started" style={{ marginLeft: 6, fontSize: 10 }}>Guest</span>
)}
</td>
<td style={{ color: 'var(--text-secondary)' }}>{project.company || '—'}</td>
<td>
<span style={{ fontWeight: 600 }}>{activeTasks.length}</span>
<span style={{ color: 'var(--text-muted)', fontSize: 12 }}> / {tasks.length} active jobs</span>
</td>
<td><StatusBadge status={project.status} /></td>
<td style={{ color: 'var(--text-secondary)' }}>{project.createdAt}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Layout>
);
}
+655 -143
View File
@@ -1,192 +1,704 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout'; import Layout from '../../components/Layout';
import StatusBadge from '../../components/StatusBadge'; import StatusBadge from '../../components/StatusBadge';
import { supabase } from '../../lib/supabase'; import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext';
import { serviceTypes } from '../../data/mockData';
import { cleanupTaskStorage } from '../../lib/deleteHelpers';
import { archiveCompletedJobsToLocalZip, restoreCompletedJobsArchive } from '../../lib/archiveHelpers';
import { readPageCache, writePageCache } from '../../lib/pageCache';
import { withTimeout } from '../../lib/withTimeout';
import { getCurrentVersionForTask, getDeadlineSourceSubmission } from '../../lib/taskDeadlines';
import { addDaysToDateOnly, formatDateOnly, getTodayDateOnlyEST } from '../../lib/dates';
import { createInitialSubmissionForRequest, createTaskForRequest, findOrCreateProject } from '../../lib/requestSubmission';
const EMPTY_FORM = () => ({ companyId: '', project: '', serviceType: '', title: '', deadline: addDaysToDateOnly(getTodayDateOnlyEST(), 3), description: '', requestedBy: '', isHot: false });
export default function Requests() { export default function Requests() {
const navigate = useNavigate(); const navigate = useNavigate();
const [submissions, setSubmissions] = useState([]); const { currentUser } = useAuth();
const [tasks, setTasks] = useState([]); const cached = readPageCache('team_requests');
const [projects, setProjects] = useState([]); const [submissions, setSubmissions] = useState(() => cached?.submissions || []);
const [companies, setCompanies] = useState([]); const [tasks, setTasks] = useState(() => cached?.tasks || []);
const [loading, setLoading] = useState(true); const [projects, setProjects] = useState(() => cached?.projects || []);
const [companies, setCompanies] = useState(() => cached?.companies || []);
const [invoices, setInvoices] = useState(() => cached?.invoices || []);
const [invoiceItems, setInvoiceItems] = useState(() => cached?.invoiceItems || []);
const [companyUsers, setCompanyUsers] = useState([]);
const [loading, setLoading] = useState(() => !cached);
const [activeTab, setActiveTab] = useState('active');
const [filterCompany, setFilterCompany] = useState(''); const [filterCompany, setFilterCompany] = useState('');
const [filterUser, setFilterUser] = useState(''); const [filterUser, setFilterUser] = useState('');
const [showAddForm, setShowAddForm] = useState(false);
const [addForm, setAddForm] = useState(EMPTY_FORM());
const [formProjects, setFormProjects] = useState([]);
const [customProjectNames, setCustomProjectNames] = useState([]);
const [isTypingProject, setIsTypingProject] = useState(false);
const [newProjectName, setNewProjectName] = useState('');
const [addSaving, setAddSaving] = useState(false);
const [addError, setAddError] = useState('');
const [addRequestKey, setAddRequestKey] = useState(() => crypto.randomUUID());
const [archivingSelected, setArchivingSelected] = useState(false);
const [archiveStatus, setArchiveStatus] = useState('');
const [restoringArchive, setRestoringArchive] = useState(false);
const [restoreStatus, setRestoreStatus] = useState('');
const [selectedTaskIds, setSelectedTaskIds] = useState([]);
const restoreInputRef = useRef(null);
const requesterOptions = [
...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)`, email: currentUser.email || '' }] : []),
...companyUsers.filter(user => user.id !== currentUser?.id),
];
useEffect(() => { useEffect(() => {
async function load() { load();
const [{ data: subs }, { data: t }, { data: p }, { data: co }] = await Promise.all([ }, []);
async function load() {
try {
const [{ data: subs }, { data: t }, { data: p }, { data: co }, { data: inv }, { data: itemRows }] = await withTimeout(Promise.all([
supabase.from('submissions').select('*').order('submitted_at', { ascending: false }), supabase.from('submissions').select('*').order('submitted_at', { ascending: false }),
supabase.from('tasks').select('*'), supabase.from('tasks').select('*'),
supabase.from('projects').select('*'), supabase.from('projects').select('*'),
supabase.from('companies').select('id, name'), supabase.from('companies').select('id, name'),
]); supabase.from('invoices').select('id, status'),
supabase.from('invoice_items').select('task_id, invoice_id'),
]), 12000, 'Requests load');
setSubmissions(subs || []); setSubmissions(subs || []);
setTasks(t || []); setTasks(t || []);
setProjects(p || []); setProjects(p || []);
setCompanies(co || []); setCompanies(co || []);
setInvoices(inv || []);
setInvoiceItems(itemRows || []);
writePageCache('team_requests', {
submissions: subs || [],
tasks: t || [],
projects: p || [],
companies: co || [],
invoices: inv || [],
invoiceItems: itemRows || [],
});
const paidInvoiceIds = new Set((inv || []).filter(invoice => invoice.status === 'paid').map(invoice => invoice.id));
const closedTaskIds = new Set(
(itemRows || [])
.filter(item => item.task_id && paidInvoiceIds.has(item.invoice_id))
.map(item => item.task_id)
);
setSelectedTaskIds(prev => prev.filter(id => (t || []).some(task => task.id === id && task.status === 'client_approved' && !(task.invoiced && closedTaskIds.has(task.id)))));
} catch (error) {
console.error('Requests load failed:', error);
setSubmissions([]);
setTasks([]);
setProjects([]);
setCompanies([]);
setInvoices([]);
setInvoiceItems([]);
} finally {
setLoading(false); setLoading(false);
} }
load(); }
}, []);
useEffect(() => {
setFormProjects([]);
setCustomProjectNames([]);
setCompanyUsers([]);
setAddForm(f => ({ ...f, project: '', requestedBy: currentUser?.id || '' }));
setIsTypingProject(false);
setNewProjectName('');
if (!addForm.companyId) return;
withTimeout(Promise.all([
supabase.from('projects').select('id, name').eq('company_id', addForm.companyId).order('name'),
supabase.from('profiles').select('id, name, email').eq('company_id', addForm.companyId).eq('role', 'client').order('name'),
]), 10000, 'Request form load').then(([projectsResult, usersResult]) => {
setFormProjects(projectsResult.data || []);
setCompanyUsers(usersResult.data || []);
}).catch((error) => {
console.error('Request form load failed:', error);
setFormProjects([]);
setCompanyUsers([]);
});
}, [addForm.companyId]);
const setAdd = (field) => (e) => setAddForm(f => ({ ...f, [field]: e.target.value }));
const handleAddProjectName = () => {
const name = newProjectName.trim();
if (!name) return;
if (!customProjectNames.includes(name) && !formProjects.some(p => p.name === name)) {
setCustomProjectNames(prev => [...prev, name]);
}
setAddForm(f => ({ ...f, project: name }));
setIsTypingProject(false);
setNewProjectName('');
};
const handleAddRequest = async (e) => {
e.preventDefault();
if (addSaving) return;
setAddSaving(true);
setAddError('');
try {
const requester = requesterOptions.find(user => user.id === addForm.requestedBy);
if (!requester) throw new Error('Please select who requested this task.');
const projectName = addForm.project.trim();
if (!projectName) throw new Error('Please select or create a project.');
const resolvedProject = await findOrCreateProject(addForm.companyId, projectName, formProjects);
if (!formProjects.some(project => project.id === resolvedProject.id)) {
setFormProjects(prev => [...prev, { id: resolvedProject.id, name: resolvedProject.name }]);
}
if (!projects.some(project => project.id === resolvedProject.id)) {
setProjects(prev => [...prev, resolvedProject]);
}
const { task } = await createTaskForRequest({
projectId: resolvedProject.id,
title: addForm.title.trim() || addForm.serviceType,
requestKey: addRequestKey,
});
if (!task) throw new Error('Failed to create task.');
const { submission: sub } = await createInitialSubmissionForRequest({
taskId: task.id,
requestKey: addRequestKey,
isHot: addForm.isHot,
serviceType: addForm.serviceType,
deadline: addForm.deadline,
description: addForm.description,
submittedBy: requester.id,
submittedByName: requester.name.replace(' (You)', ''),
});
if (!sub) throw new Error('Failed to create submission.');
// Refresh list
const [{ data: newSubs }, { data: newTasks }] = await Promise.all([
supabase.from('submissions').select('*').order('submitted_at', { ascending: false }),
supabase.from('tasks').select('*'),
]);
setSubmissions(newSubs || []);
setTasks(newTasks || []);
setShowAddForm(false);
setAddForm(EMPTY_FORM());
setAddRequestKey(crypto.randomUUID());
setCustomProjectNames([]);
setIsTypingProject(false);
setNewProjectName('');
} catch (err) {
setAddError(err.message);
} finally {
setAddSaving(false);
}
};
const handleArchiveSelected = async () => {
if (!selectedTaskIds.length) {
alert('Select at least one completed job to archive.');
return;
}
if (!window.confirm(`Download an archive for ${selectedTaskIds.length} selected completed job${selectedTaskIds.length === 1 ? '' : 's'}?`)) return;
setArchivingSelected(true);
try {
const archive = await archiveCompletedJobsToLocalZip(selectedTaskIds, { onProgress: setArchiveStatus });
const shouldDelete = window.confirm(
`Archive downloaded as "${archive.filename}".\n\nRemove those completed jobs from Supabase now?`
);
if (shouldDelete) {
await cleanupTaskStorage(selectedTaskIds);
await supabase.from('tasks').delete().in('id', selectedTaskIds);
setSelectedTaskIds([]);
await load();
setArchiveStatus('');
alert(`Archived and removed ${archive.taskCount} completed job${archive.taskCount === 1 ? '' : 's'}.`);
}
} catch (error) {
alert(`Archive failed: ${error.message}`);
} finally {
setArchivingSelected(false);
setArchiveStatus('');
}
};
const handleRestoreArchive = async (e) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
setRestoringArchive(true);
try {
const result = await restoreCompletedJobsArchive(file, { onProgress: setRestoreStatus });
await load();
const notes = [];
if (result.clearedAssignments) notes.push(`${result.clearedAssignments} assignments cleared`);
if (result.clearedSubmissionUsers) notes.push(`${result.clearedSubmissionUsers} submission user links cleared`);
alert(
notes.length
? `Restored ${result.taskCount} completed job${result.taskCount === 1 ? '' : 's'}.\n\nNote: ${notes.join('; ')}.`
: `Restored ${result.taskCount} completed job${result.taskCount === 1 ? '' : 's'}.`
);
} catch (error) {
alert(`Restore failed: ${error.message}`);
} finally {
setRestoringArchive(false);
setRestoreStatus('');
}
};
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>; if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
const requesterNames = [...new Set(submissions.map(s => s.submitted_by_name).filter(Boolean))].sort();
const paidInvoiceIds = new Set(invoices.filter(invoice => invoice.status === 'paid').map(invoice => invoice.id));
const paidTaskIds = new Set(
invoiceItems
.filter(item => item.task_id && paidInvoiceIds.has(item.invoice_id))
.map(item => item.task_id)
);
const isFullyClosedTask = (task) => task?.status === 'client_approved' && Boolean(task?.invoiced) && paidTaskIds.has(task.id);
const latestTaskGroups = tasks.map(task => {
const taskSubs = submissions.filter(sub => sub.task_id === task.id);
const deadlineSource = getDeadlineSourceSubmission(task, taskSubs);
if (!deadlineSource) return null;
const currentVersion = getCurrentVersionForTask(task, taskSubs);
const latestGroup = taskSubs.filter(sub => sub.version_number === currentVersion);
return { task, primary: deadlineSource, group: latestGroup };
}).filter(Boolean);
const completedTaskIds = latestTaskGroups
.filter(({ task }) => task.status === 'client_approved' && !isFullyClosedTask(task))
.map(({ task }) => task.id);
const allCompletedSelected = completedTaskIds.length > 0 && selectedTaskIds.length === completedTaskIds.length;
const toggleSelectedTask = (taskId) => {
setSelectedTaskIds(prev => prev.includes(taskId)
? prev.filter(id => id !== taskId)
: [...prev, taskId]
);
};
const toggleSelectAllCompleted = () => {
setSelectedTaskIds(allCompletedSelected ? [] : completedTaskIds);
};
const filteredGroups = latestTaskGroups.filter(({ task, group }) => {
const project = projects.find(p => p.id === task?.project_id);
if (filterCompany && project?.company_id !== filterCompany) return false;
if (filterUser && !group.some(s => s.submitted_by_name === filterUser)) return false;
return true;
}).sort((a, b) => {
const aLatest = Math.max(...a.group.map(s => new Date(s.submitted_at).getTime()));
const bLatest = Math.max(...b.group.map(s => new Date(s.submitted_at).getTime()));
return bLatest - aLatest;
});
const activeGroups = filteredGroups.filter(({ task }) => task?.status !== 'client_approved' && task?.status !== 'client_review');
const clientReviewGroups = filteredGroups.filter(({ task }) => task?.status === 'client_review');
const completedGroups = filteredGroups.filter(({ task }) => task?.status === 'client_approved' && !isFullyClosedTask(task));
const closedGroups = filteredGroups.filter(({ task }) => isFullyClosedTask(task));
const renderRow = ({ task, primary }, showCheckbox = false) => {
const project = projects.find(p => p.id === task?.project_id);
const company = companies.find(co => co.id === project?.company_id);
const isCompleted = task?.status === 'client_approved';
const isFullyClosed = isFullyClosedTask(task);
const revisionLabel = `R${String(primary.version_number ?? 0).padStart(2, '0')}`;
const deadline = formatDateOnly(primary.deadline, 'Not specified');
return (
<tr key={task.id} onClick={() => task && navigate(`/tasks/${task.id}`)} style={{ cursor: task ? 'pointer' : 'default' }}>
{showCheckbox && (
<td onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedTaskIds.includes(task.id)}
disabled={!isCompleted}
onChange={() => toggleSelectedTask(task.id)}
onClick={e => e.stopPropagation()}
/>
</td>
)}
<td style={{ fontWeight: 600 }}>{project?.name || 'No project'}</td>
<td style={{ fontWeight: 600 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span>{task?.title || primary.service_type}</span>
{primary.is_hot ? <span className="badge badge-needs_revision">Hot</span> : null}
</div>
</td>
<td>{revisionLabel}</td>
<td>{primary.service_type || 'Request'}</td>
<td>
{company
? <Link to={`/companies/${company.id}`} className="table-link" onClick={e => e.stopPropagation()}>{company.name}</Link>
: 'No client'}
</td>
<td>{deadline}</td>
<td>{isFullyClosed ? <span className="badge badge-client_approved">Paid & Closed</span> : isCompleted ? <span className="badge badge-client_approved">Completed</span> : <StatusBadge status={task?.status || 'not_started'} />}</td>
</tr>
);
};
return ( return (
<Layout> <Layout>
<div className="page-header"> <div className="page-header">
<div> <div>
<div className="page-title">Requests Inbox</div> <div className="page-title">Requests</div>
<div className="page-subtitle">All incoming submissions initial requests and revisions.</div> <div className="page-subtitle">Track incoming requests, revisions, and completed jobs in one place.</div>
</div> </div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-primary btn-sm" onClick={() => { setShowAddForm(s => !s); setAddError(''); }}>
{showAddForm ? 'Cancel' : '+ Add Request'}
</button>
</div>
<input
ref={restoreInputRef}
type="file"
accept=".zip,application/zip"
style={{ display: 'none' }}
onChange={handleRestoreArchive}
/>
</div> </div>
{companies.length > 0 && ( {showAddForm && (
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 10 }}> <div className="card" style={{ marginBottom: 24, border: '1px solid var(--accent)', borderRadius: 12 }}>
<button className={`btn btn-sm ${!filterCompany ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterCompany('')}>All</button> <div className="card-title">Add Request</div>
{companies.map(co => ( <form onSubmit={handleAddRequest}>
<button <div className="grid-2">
key={co.id} <div className="form-group">
className={`btn btn-sm ${filterCompany === co.id ? 'btn-primary' : 'btn-outline'}`} <label>Company *</label>
onClick={() => setFilterCompany(f => f === co.id ? '' : co.id)} <select value={addForm.companyId} onChange={setAdd('companyId')} required>
> <option value="">Select company...</option>
{co.name} {companies.map(co => <option key={co.id} value={co.id}>{co.name}</option>)}
</button> </select>
))} </div>
<div className="form-group">
<label>Project *</label>
{isTypingProject ? (
<div style={{ display: 'flex', gap: 8 }}>
<input
type="text"
placeholder="Enter project name..."
value={newProjectName}
onChange={e => setNewProjectName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleAddProjectName(); } }}
autoFocus
style={{ flex: 1 }}
/>
<button type="button" className="btn btn-primary btn-sm" onClick={handleAddProjectName} disabled={!newProjectName.trim()}>Add</button>
<button type="button" className="btn btn-outline btn-sm" onClick={() => { setIsTypingProject(false); setNewProjectName(''); }}>Cancel</button>
</div>
) : (
<select
value={addForm.project}
onChange={e => {
if (e.target.value === '__new__') { setIsTypingProject(true); setAddForm(f => ({ ...f, project: '' })); }
else { setAddForm(f => ({ ...f, project: e.target.value })); }
}}
required
disabled={!addForm.companyId}
>
<option value="">{addForm.companyId ? 'Select project...' : 'Select company first'}</option>
{[...formProjects.map(p => p.name), ...customProjectNames.filter(n => !formProjects.some(p => p.name === n))].map(name => (
<option key={name} value={name}>{name}</option>
))}
{addForm.companyId && <option value="__new__"> Create new project...</option>}
</select>
)}
</div>
</div>
<div className="grid-2">
<div className="form-group">
<label>Service Type *</label>
<select value={addForm.serviceType} onChange={setAdd('serviceType')} required>
<option value="">Select service...</option>
{serviceTypes.map(s => <option key={s} value={s}>{s}</option>)}
</select>
</div>
<div className="form-group">
<label>Deadline <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
<input type="date" value={addForm.deadline} onChange={setAdd('deadline')} />
</div>
</div>
<div className="form-group" style={{ marginTop: -4 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 10, fontWeight: 500, cursor: 'pointer' }}>
<input
type="checkbox"
checked={addForm.isHot}
onChange={e => setAddForm(f => ({ ...f, isHot: e.target.checked }))}
/>
<span>Mark as Hot</span>
</label>
</div>
<div className="form-group">
<label>Requested By *</label>
<select value={addForm.requestedBy} onChange={setAdd('requestedBy')} disabled={!addForm.companyId} required>
<option value="">{addForm.companyId ? 'Select requester...' : 'Select company first'}</option>
{requesterOptions.map(user => (
<option key={user.id} value={user.id}>
{user.name}{user.email ? ` (${user.email})` : ''}
</option>
))}
</select>
</div>
<div className="form-group">
<label>Title <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional defaults to service type)</span></label>
<input type="text" placeholder={addForm.serviceType || 'e.g. Brand Book — 2026'} value={addForm.title} onChange={setAdd('title')} />
</div>
<div className="form-group">
<label>Description *</label>
<textarea placeholder="Notes on the request..." value={addForm.description} onChange={setAdd('description')} style={{ minHeight: 100 }} required />
</div>
{addError && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}> {addError}</div>}
<div className="action-buttons">
<button type="submit" className="btn btn-primary" disabled={addSaving}>{addSaving ? 'Adding...' : 'Add Request'}</button>
<button type="button" className="btn btn-outline" onClick={() => { setShowAddForm(false); setAddForm(EMPTY_FORM()); setCustomProjectNames([]); setIsTypingProject(false); setNewProjectName(''); setAddError(''); }}>Cancel</button>
</div>
</form>
</div> </div>
)} )}
{[...new Set(submissions.map(s => s.submitted_by_name).filter(Boolean))].sort().length > 0 && (
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 20 }}> <div className="card request-toolbar-card">
<button className={`btn btn-sm ${!filterUser ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterUser('')}>All Users</button> <div className="request-toolbar-section">
{[...new Set(submissions.map(s => s.submitted_by_name).filter(Boolean))].sort().map(name => ( <div className="card-title" style={{ marginBottom: 10 }}>Archive</div>
<div className="request-toolbar-actions">
{completedTaskIds.length > 0 && (
<>
<label className="request-select-all-label">
<input type="checkbox" checked={allCompletedSelected} onChange={toggleSelectAllCompleted} />
Select All Completed
</label>
<button
className="btn btn-outline btn-sm"
onClick={handleArchiveSelected}
disabled={archivingSelected || !selectedTaskIds.length}
>
{archivingSelected ? 'Archiving...' : `Archive Selected (${selectedTaskIds.length})`}
</button>
</>
)}
<button <button
key={name} className="btn btn-outline btn-sm"
className={`btn btn-sm ${filterUser === name ? 'btn-primary' : 'btn-outline'}`} onClick={() => restoreInputRef.current?.click()}
onClick={() => setFilterUser(f => f === name ? '' : name)} disabled={restoringArchive}
> >
{name} {restoringArchive ? 'Restoring...' : 'Restore Archive'}
</button> </button>
))} </div>
{archiveStatus && <div className="request-toolbar-status">{archiveStatus}</div>}
{restoreStatus && <div className="request-toolbar-status">{restoreStatus}</div>}
</div> </div>
)}
{(companies.length > 0 || requesterNames.length > 0) && (
<div className="request-toolbar-grid">
{companies.length > 0 && (
<div className="request-toolbar-section">
<div className="card-title" style={{ marginBottom: 10 }}>Filter By Company</div>
<div className="request-filter-row">
<button className={`btn btn-sm ${!filterCompany ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterCompany('')}>All</button>
{companies.map(co => (
<button
key={co.id}
className={`btn btn-sm ${filterCompany === co.id ? 'btn-primary' : 'btn-outline'}`}
onClick={() => setFilterCompany(f => f === co.id ? '' : co.id)}
>
{co.name}
</button>
))}
</div>
</div>
)}
{requesterNames.length > 0 && (
<div className="request-toolbar-section">
<div className="card-title" style={{ marginBottom: 10 }}>Filter By Requester</div>
<div className="request-filter-row">
<button className={`btn btn-sm ${!filterUser ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterUser('')}>All</button>
{requesterNames.map(name => (
<button
key={name}
className={`btn btn-sm ${filterUser === name ? 'btn-primary' : 'btn-outline'}`}
onClick={() => setFilterUser(f => f === name ? '' : name)}
>
{name}
</button>
))}
</div>
</div>
)}
</div>
)}
</div>
{submissions.length === 0 ? ( {submissions.length === 0 ? (
<div className="empty-state"> <div className="empty-state">
<h3>No requests yet</h3> <h3>No requests yet</h3>
<p>Client requests will appear here.</p> <p>Client requests will appear here.</p>
</div> </div>
) : (() => { ) : filteredGroups.length === 0 ? (
// Group by task_id + version_number <div className="empty-state">
const groupMap = {}; <h3>No matching requests</h3>
submissions.forEach(sub => { <p>Try clearing the current company or requester filters.</p>
const key = `${sub.task_id}-${sub.version_number}`; </div>
if (!groupMap[key]) groupMap[key] = []; ) : (
groupMap[key].push(sub); <div>
}); <div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div className="card-title" style={{ marginBottom: 0, display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
// Sort groups by latest submitted_at descending, then apply filters {[
const groups = Object.values(groupMap).filter(group => { { id: 'active', label: 'Active', count: activeGroups.length },
const primary = group.find(s => s.type !== 'amendment') || group[0]; { id: 'client-review', label: 'Client Review', count: clientReviewGroups.length },
const task = tasks.find(t => t.id === primary.task_id); { id: 'completed', label: 'Completed', count: completedGroups.length },
const project = projects.find(p => p.id === task?.project_id); { id: 'closed', label: 'Fully Closed', count: closedGroups.length },
if (filterCompany && project?.company_id !== filterCompany) return false; ].map((tab, index) => (
if (filterUser && !group.some(s => s.submitted_by_name === filterUser)) return false; <span key={tab.id} style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
return true; {index > 0 && <span style={{ color: 'var(--text-muted)' }}>|</span>}
}).sort((a, b) => { <button
const aMax = Math.max(...a.map(s => new Date(s.submitted_at))); type="button"
const bMax = Math.max(...b.map(s => new Date(s.submitted_at))); onClick={() => setActiveTab(tab.id)}
return bMax - aMax; style={{
}); background: 'none',
border: 'none',
// Group by company padding: 0,
const byCompany = {}; margin: 0,
groups.forEach(group => { cursor: 'pointer',
const primary = group.find(s => s.type !== 'amendment') || group[0]; color: activeTab === tab.id ? 'var(--text-primary)' : 'var(--text-muted)',
const task = tasks.find(t => t.id === primary.task_id); font: 'inherit',
const project = projects.find(p => p.id === task?.project_id); textTransform: 'inherit',
const company = companies.find(co => co.id === project?.company_id); letterSpacing: 'inherit',
const key = company?.id || '__none__'; }}
if (!byCompany[key]) byCompany[key] = { company, groups: [] }; >
byCompany[key].groups.push(group); {tab.label}
}); <span className="request-company-count" style={{ marginLeft: 6 }}>{tab.count}</span>
</button>
const renderCard = (group) => { </span>
const primary = group.find(s => s.type !== 'amendment') || group[0];
const amendments = group.filter(s => s.type === 'amendment');
const task = tasks.find(t => t.id === primary.task_id);
const project = projects.find(p => p.id === task?.project_id);
const company = companies.find(co => co.id === project?.company_id);
const isCompleted = task?.status === 'client_approved';
return (
<div key={primary.id} className="request-card" style={{ cursor: task ? 'pointer' : 'default' }} onClick={() => task && navigate(`/tasks/${task.id}`)}>
<div className="request-card-header">
<div>
<div className="request-card-title">
{task?.title || primary.service_type}
<StatusBadge status={primary.type} />
</div>
<div className="request-card-meta">
From <strong>{primary.submitted_by_name}</strong>
{company && (
<> · <Link to={`/companies/${company.id}`} className="table-link" onClick={e => e.stopPropagation()}>{company.name}</Link></>
)}
{' · '}{new Date(primary.submitted_at).toLocaleDateString()}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{isCompleted
? <span className="badge badge-client_approved">Completed</span>
: <StatusBadge status={task?.status || 'not_started'} />
}
</div>
</div>
<div style={{ display: 'flex', gap: 24, marginBottom: 12 }}>
<div>
<span style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Deadline</span>
<div style={{ fontSize: 13, marginTop: 2 }}>{primary.deadline || 'Not specified'}</div>
</div>
<div>
<span style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Project</span>
<div style={{ fontSize: 13, marginTop: 2 }}>
{project ? <Link to={`/projects/${project.id}`} className="table-link" onClick={e => e.stopPropagation()}>{project.name}</Link> : '—'}
</div>
</div>
</div>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.6, marginBottom: amendments.length > 0 ? 12 : 0 }}>{primary.description}</p>
{amendments.map(amendment => (
<div key={amendment.id} style={{ padding: '12px 14px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)', marginTop: 8 }}>
<div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', color: 'var(--accent)', marginBottom: 6, letterSpacing: 0.5 }}>
Amended Request
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 6 }}>
{amendment.submitted_by_name} · {new Date(amendment.submitted_at).toLocaleDateString()}
</div>
<p style={{ fontSize: 13, lineHeight: 1.6, color: 'var(--text-secondary)', whiteSpace: 'pre-wrap', margin: 0 }}>{amendment.description}</p>
</div>
))} ))}
</div> </div>
);
};
return Object.values(byCompany).map(({ company, groups: companyGroups }) => (
<div key={company?.id || '__none__'} style={{ marginBottom: 32 }}>
<div style={{ fontSize: 13, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.6px', color: 'var(--text-muted)', marginBottom: 10 }}>
{company
? <Link to={`/companies/${company.id}`} style={{ color: 'var(--text-muted)', textDecoration: 'none' }}>{company.name}</Link>
: 'No Company'
}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{companyGroups.map(renderCard)}
</div>
</div> </div>
));
})()} {activeTab === 'active' && (
<div>
{activeGroups.length === 0 ? (
<div className="empty-state" style={{ padding: '28px 20px' }}>
<h3>No active requests</h3>
</div>
) : (
<div className="table-wrapper">
<table>
<thead>
<tr>
<th>Project</th>
<th>Name</th>
<th>Revision</th>
<th>Request Type</th>
<th>Client</th>
<th>Deadline</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{activeGroups.map(group => renderRow(group))}
</tbody>
</table>
</div>
)}
</div>
)}
{activeTab === 'client-review' && (
<div>
{clientReviewGroups.length === 0 ? (
<div className="empty-state" style={{ padding: '28px 20px' }}>
<h3>No requests in client review</h3>
</div>
) : (
<div className="table-wrapper">
<table>
<thead>
<tr>
<th>Project</th>
<th>Name</th>
<th>Revision</th>
<th>Request Type</th>
<th>Client</th>
<th>Deadline</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{clientReviewGroups.map(group => renderRow(group))}
</tbody>
</table>
</div>
)}
</div>
)}
{activeTab === 'completed' && (
<div>
{completedGroups.length === 0 ? (
<div className="empty-state" style={{ padding: '28px 20px' }}>
<h3>No completed requests</h3>
</div>
) : (
<div className="table-wrapper">
<table>
<thead>
<tr>
<th></th>
<th>Project</th>
<th>Name</th>
<th>Revision</th>
<th>Request Type</th>
<th>Client</th>
<th>Deadline</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{completedGroups.map(group => renderRow(group, true))}
</tbody>
</table>
</div>
)}
</div>
)}
{activeTab === 'closed' && (
<div>
{closedGroups.length === 0 ? (
<div className="empty-state" style={{ padding: '28px 20px' }}>
<h3>No fully closed requests</h3>
<p>Requests move here once they are completed, invoiced, and paid.</p>
</div>
) : (
<div className="table-wrapper">
<table>
<thead>
<tr>
<th>Project</th>
<th>Name</th>
<th>Revision</th>
<th>Request Type</th>
<th>Client</th>
<th>Deadline</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{closedGroups.map(group => renderRow(group))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
)}
</Layout> </Layout>
); );
} }
+253
View File
@@ -0,0 +1,253 @@
import { useEffect, useState } from 'react';
import Layout from '../../components/Layout';
import { supabase } from '../../lib/supabase';
const STATUS_STYLE = {
none: { bg: 'rgba(34,197,94,0.15)', border: 'rgba(34,197,94,0.3)', color: '#4ade80', label: 'Operational' },
minor: { bg: 'rgba(245,165,35,0.15)', border: 'rgba(245,165,35,0.3)', color: '#f5a523', label: 'Minor Issues' },
major: { bg: 'rgba(239,68,68,0.15)', border: 'rgba(239,68,68,0.3)', color: '#f87171', label: 'Major Outage' },
critical: { bg: 'rgba(239,68,68,0.15)', border: 'rgba(239,68,68,0.3)', color: '#f87171', label: 'Critical Outage' },
maintenance: { bg: 'rgba(96,165,250,0.15)', border: 'rgba(96,165,250,0.3)', color: '#93c5fd', label: 'Maintenance' },
};
function formatBytes(bytes) {
if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return 'Unavailable';
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const value = bytes / 1024 ** index;
return `${value >= 100 ? value.toFixed(0) : value.toFixed(value >= 10 ? 1 : 2)} ${units[index]}`;
}
function formatValue(value, unit) {
if (value === null || value === undefined) return 'Unavailable';
if (unit === 'bytes') return formatBytes(value);
if (unit === 'hours') return `${Number(value).toFixed(Number(value) >= 10 ? 0 : 1)} h`;
return Number(value).toLocaleString();
}
function formatDate(value) {
if (!value) return 'Unavailable';
return new Date(value).toLocaleString();
}
function StatusPill({ indicator, description }) {
const style = STATUS_STYLE[indicator] || STATUS_STYLE.major;
return (
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 8,
padding: '6px 12px',
borderRadius: 999,
background: style.bg,
border: `1px solid ${style.border}`,
color: style.color,
fontSize: 12,
fontWeight: 700,
}}
>
<span>{style.label}</span>
{description ? <span style={{ opacity: 0.9, fontWeight: 500 }}>· {description}</span> : null}
</div>
);
}
function UsageBar({ quota }) {
const canMeasure = quota.used !== null && quota.used !== undefined && quota.limit;
const percent = canMeasure ? Math.min((quota.used / quota.limit) * 100, 100) : 0;
const remaining = canMeasure ? Math.max(quota.limit - quota.used, 0) : null;
const overLimit = canMeasure && quota.used > quota.limit;
const tone = overLimit ? 'var(--danger)' : percent >= 85 ? '#f5a523' : 'var(--accent)';
return (
<div className="status-meter">
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'baseline' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)' }}>{quota.label}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>{quota.note}</div>
</div>
<div style={{ textAlign: 'right', flexShrink: 0 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)' }}>
{formatValue(quota.used, quota.unit)} / {formatValue(quota.limit, quota.unit)}
</div>
<div style={{ fontSize: 12, color: overLimit ? 'var(--danger)' : 'var(--text-muted)' }}>
{canMeasure ? `${percent.toFixed(1)}% used` : quota.source === 'manual' ? 'Manual reading' : 'Not available yet'}
</div>
</div>
</div>
<div className="status-meter-track">
<div className="status-meter-fill" style={{ width: `${percent}%`, background: tone }} />
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{canMeasure
? overLimit
? `${formatValue(quota.used - quota.limit, quota.unit)} over the free-tier reference.`
: `${formatValue(remaining, quota.unit)} left before the free-tier reference.`
: 'No current reading is available for this metric.'}
</div>
</div>
);
}
function StatsGrid({ stats }) {
if (!stats?.length) return null;
return (
<div className="status-stats-grid">
{stats.map((stat) => (
<div key={stat.label} className="status-stat-card">
<div className="status-stat-value">{Number(stat.value).toLocaleString()}</div>
<div className="status-stat-label">{stat.label}</div>
</div>
))}
</div>
);
}
function ServiceCard({ service }) {
const topComponents = (service.status.components || []).slice(0, 6);
const visibleQuotas = service.usage.quotas || [];
return (
<section className="card" style={{ padding: 22 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 16, alignItems: 'flex-start', flexWrap: 'wrap' }}>
<div>
<div className="card-title" style={{ marginBottom: 8 }}>{service.name}</div>
<StatusPill indicator={service.status.status?.indicator} description={service.status.status?.description} />
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 10 }}>
Last checked {formatDate(service.status.updatedAt)}
</div>
</div>
<div style={{ minWidth: 220, fontSize: 12, color: 'var(--text-muted)' }}>
{service.usage.message ? (
<div style={{ padding: '10px 12px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--card-bg-2)' }}>
{service.usage.message}
</div>
) : (
<div style={{ padding: '10px 12px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--card-bg-2)' }}>
Free-tier bars show current usage where the server can measure it.
</div>
)}
</div>
</div>
{topComponents.length > 0 && (
<div style={{ marginTop: 18 }}>
<div style={{ fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.6, color: 'var(--text-muted)', marginBottom: 10 }}>
Components
</div>
<div className="status-components-grid">
{topComponents.map((component) => (
<div key={component.id} className="status-component">
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{component.name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 4, textTransform: 'capitalize' }}>
{component.status.replaceAll('_', ' ')}
</div>
</div>
))}
</div>
</div>
)}
<StatsGrid stats={service.usage.stats} />
<div style={{ display: 'grid', gap: 14, marginTop: service.usage.stats?.length ? 18 : 22 }}>
{visibleQuotas.map((quota) => (
<UsageBar key={quota.id} quota={quota} />
))}
</div>
{service.usage.buckets?.length > 0 && (
<div style={{ marginTop: 18 }}>
<div style={{ fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.6, color: 'var(--text-muted)', marginBottom: 10 }}>
Biggest Supabase Buckets
</div>
<div className="status-components-grid">
{service.usage.buckets.map((bucket) => (
<div key={bucket.bucketId} className="status-component">
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{bucket.bucketId}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 4 }}>{formatBytes(bucket.bytes)}</div>
</div>
))}
</div>
</div>
)}
</section>
);
}
export default function ServerStatus() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [payload, setPayload] = useState(null);
const load = async (cancelledRef) => {
setLoading(true);
setError('');
try {
const { data: sessionData, error: sessionError } = await supabase.auth.getSession();
if (sessionError) throw sessionError;
const accessToken = sessionData.session?.access_token;
if (!accessToken) throw new Error('You must be signed in to view server status.');
const response = await fetch('/api/server-status', {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
const json = await response.json();
if (!response.ok) {
throw new Error(json.error || 'Unable to load server status.');
}
if (!cancelledRef.current) {
setPayload(json);
}
} catch (err) {
if (!cancelledRef.current) {
setError(err.message || 'Unable to load server status.');
}
} finally {
if (!cancelledRef.current) {
setLoading(false);
}
}
};
useEffect(() => {
const cancelledRef = { current: false };
load(cancelledRef);
return () => {
cancelledRef.current = true;
};
}, []);
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">Server Status</div>
<div className="page-subtitle">Live service health plus free-tier usage references for Supabase and Vercel.</div>
</div>
<button className="btn btn-outline btn-sm" onClick={() => load({ current: false })} disabled={loading}>
{loading ? 'Refreshing...' : 'Refresh'}
</button>
</div>
{loading ? (
<div className="card" style={{ padding: 28, color: 'var(--text-muted)' }}>Loading current service status</div>
) : error ? (
<div className="card" style={{ padding: 28, borderColor: 'rgba(239,68,68,0.4)', color: '#fca5a5' }}>{error}</div>
) : (
<div className="server-status-grid">
<ServiceCard service={payload.services.supabase} />
<ServiceCard service={payload.services.vercel} />
</div>
)}
</Layout>
);
}
-802
View File
@@ -1,802 +0,0 @@
import { useState, useEffect, useRef } from 'react';
import Layout from '../../components/Layout';
import { supabase } from '../../lib/supabase';
import { generateBrandBookEditorPDF } from '../../lib/signsurvey';
const BUCKET = 'brand-books';
const EMPTY_SIGN = () => ({
_key: Math.random().toString(36).slice(2),
signNumber: '',
type: '',
location: '',
width: '',
height: '',
material: '',
illumination: '',
condition: '',
mountType: '',
notes: '',
photo: null,
photoPath: '',
_photoPreview: '',
});
const EMPTY_BOOK_INFO = {
clientId: '',
clientName: '',
projectName: '',
siteAddress: '',
bookDate: new Date().toISOString().split('T')[0],
preparedBy: '',
revision: '01',
};
// ─── Helpers ──────────────────────────────────────────────────────────────────
const getSignedUrl = async (path) => {
if (!path) return null;
const { data } = await supabase.storage.from(BUCKET).createSignedUrl(path, 86400 * 7);
return data?.signedUrl || null;
};
const uploadFile = async (file, path) => {
const { error } = await supabase.storage.from(BUCKET).upload(path, file, { upsert: true });
if (error) throw error;
return path;
};
// ─── Main Component ───────────────────────────────────────────────────────────
export default function BrandBook() {
const [view, setView] = useState('list');
const [savedBooks, setSavedBooks] = useState([]);
const [loadingBooks, setLoadingBooks] = useState(true);
const [currentId, setCurrentId] = useState(null);
const [clients, setClients] = useState([]);
const [bookInfo, setBookInfo] = useState(EMPTY_BOOK_INFO);
const [siteMapFile, setSiteMapFile] = useState(null);
const [siteMapPath, setSiteMapPath] = useState('');
const [siteMapPreview, setSiteMapPreview] = useState(null);
const [signs, setSigns] = useState([EMPTY_SIGN()]);
const [photoItems, setPhotoItems] = useState([]);
const [expandedSign, setExpandedSign] = useState(0);
const [generating, setGenerating] = useState(false);
const [saving, setSaving] = useState(false);
const [notification, setNotification] = useState(null);
const siteMapRef = useRef();
const sitePhotosRef = useRef();
useEffect(() => {
supabase.from('companies').select('id, name').order('name').then(({ data }) => setClients(data || []));
fetchBooks();
}, []);
const fetchBooks = async () => {
setLoadingBooks(true);
const { data } = await supabase.from('brand_books').select('*').order('updated_at', { ascending: false });
setSavedBooks(data || []);
setLoadingBooks(false);
};
const set = (field) => (e) => setBookInfo(b => ({ ...b, [field]: e.target.value }));
const handleClientChange = (e) => {
const id = e.target.value;
const client = clients.find(c => c.id === id);
setBookInfo(b => ({ ...b, clientId: id, clientName: client ? client.name : '' }));
};
const resetForm = () => {
setBookInfo(EMPTY_BOOK_INFO);
setSiteMapFile(null);
setSiteMapPath('');
setSiteMapPreview(null);
setSigns([EMPTY_SIGN()]);
setPhotoItems([]);
setCurrentId(null);
setExpandedSign(0);
setNotification(null);
};
const handleNew = () => { resetForm(); setView('form'); };
const buildLoadedState = async (book) => {
const siteMapSignedUrl = await getSignedUrl(book.site_map_path);
const signsWithPreviews = await Promise.all((book.signs || []).map(async (sign) => {
const preview = await getSignedUrl(sign.photoPath);
return { ...sign, photo: null, _photoPreview: preview || '' };
}));
const surveyItems = await Promise.all((book.survey_photo_paths || []).map(async (path) => {
const preview = await getSignedUrl(path);
return { file: null, path, preview: preview || '' };
}));
return { siteMapSignedUrl, signsWithPreviews, surveyItems };
};
const handleLoad = async (book) => {
setNotification(null);
const { siteMapSignedUrl, signsWithPreviews, surveyItems } = await buildLoadedState(book);
setBookInfo({
clientId: book.client_id || '',
clientName: book.client_name || '',
projectName: book.project_name || '',
siteAddress: book.site_address || '',
bookDate: book.book_date || new Date().toISOString().split('T')[0],
preparedBy: book.prepared_by || '',
revision: book.revision || '01',
});
setSiteMapFile(null);
setSiteMapPath(book.site_map_path || '');
setSiteMapPreview(siteMapSignedUrl);
setSigns(signsWithPreviews.length > 0 ? signsWithPreviews : [EMPTY_SIGN()]);
setPhotoItems(surveyItems);
setCurrentId(book.id);
setExpandedSign(null);
setView('form');
};
const handleAddRevision = async (book) => {
setNotification(null);
const { siteMapSignedUrl, signsWithPreviews, surveyItems } = await buildLoadedState(book);
const nextRev = String((parseInt(book.revision || '1', 10) + 1)).padStart(2, '0');
setBookInfo({
clientId: book.client_id || '',
clientName: book.client_name || '',
projectName: book.project_name || '',
siteAddress: book.site_address || '',
bookDate: new Date().toISOString().split('T')[0],
preparedBy: book.prepared_by || '',
revision: nextRev,
});
setSiteMapFile(null);
setSiteMapPath(book.site_map_path || '');
setSiteMapPreview(siteMapSignedUrl);
setSigns(signsWithPreviews.length > 0 ? signsWithPreviews : [EMPTY_SIGN()]);
setPhotoItems(surveyItems);
setCurrentId(null); // new entry on save
setExpandedSign(null);
setView('form');
};
const handleDelete = async (id) => {
if (!window.confirm('Delete this brand book? This cannot be undone.')) return;
await supabase.from('brand_books').delete().eq('id', id);
fetchBooks();
};
const handleSave = async () => {
if (!bookInfo.clientName.trim()) {
setNotification({ type: 'error', msg: 'Please select a client.' });
return;
}
setSaving(true);
setNotification(null);
try {
const bookId = currentId || crypto.randomUUID();
// Upload site map if new file
let finalSiteMapPath = siteMapPath;
if (siteMapFile) {
const ext = siteMapFile.name.split('.').pop().toLowerCase();
finalSiteMapPath = `${bookId}/site-map.${ext}`;
await uploadFile(siteMapFile, finalSiteMapPath);
}
// Upload sign photos if new file
const finalSigns = await Promise.all(signs.map(async (sign) => {
let photoPath = sign.photoPath || '';
if (sign.photo) {
const ext = sign.photo.name.split('.').pop().toLowerCase();
photoPath = `${bookId}/sign-${sign._key}.${ext}`;
await uploadFile(sign.photo, photoPath);
}
const { photo, _photoPreview, ...rest } = sign;
return { ...rest, photoPath };
}));
// Upload survey photos if new file
const finalSurveyPaths = await Promise.all(photoItems.map(async (item, i) => {
if (item.file) {
const ext = item.file.name.split('.').pop().toLowerCase();
const path = `${bookId}/survey-${i}-${Date.now()}.${ext}`;
await uploadFile(item.file, path);
return path;
}
return item.path;
}));
const dbData = {
id: bookId,
client_id: bookInfo.clientId || null,
client_name: bookInfo.clientName,
project_name: bookInfo.projectName,
site_address: bookInfo.siteAddress,
book_date: bookInfo.bookDate || null,
prepared_by: bookInfo.preparedBy,
revision: bookInfo.revision,
site_map_path: finalSiteMapPath,
signs: finalSigns,
survey_photo_paths: finalSurveyPaths.filter(Boolean),
updated_at: new Date().toISOString(),
};
await supabase.from('brand_books').upsert(dbData);
setCurrentId(bookId);
setSiteMapPath(finalSiteMapPath);
setSiteMapFile(null);
setSigns(finalSigns.map(s => ({ ...s, photo: null })));
setPhotoItems(finalSurveyPaths.filter(Boolean).map((path, i) => ({
file: null,
path,
preview: photoItems[i]?.preview || '',
})));
await fetchBooks();
setNotification({ type: 'success', msg: '✓ Brand book saved!' });
} catch (err) {
setNotification({ type: 'error', msg: `Save failed: ${err.message}` });
}
setSaving(false);
};
const handleGenerate = async () => {
if (!bookInfo.clientName.trim()) {
setNotification({ type: 'error', msg: 'Please select a client.' });
return;
}
setGenerating(true);
setNotification(null);
try {
const siteMapSource = siteMapFile || (siteMapPath ? await getSignedUrl(siteMapPath) : null);
const signsWithSource = await Promise.all(signs.map(async s => ({
...s,
photoSource: s.photo || (s.photoPath ? await getSignedUrl(s.photoPath) : null),
})));
const sitePhotoSources = await Promise.all(photoItems.map(async item =>
item.file || (item.path ? await getSignedUrl(item.path) : null)
));
await generateBrandBookEditorPDF({
clientName: bookInfo.clientName,
projectName: bookInfo.projectName,
siteAddress: bookInfo.siteAddress,
bookDate: bookInfo.bookDate,
preparedBy: bookInfo.preparedBy,
revision: bookInfo.revision,
siteMapSource,
signs: signsWithSource,
sitePhotoSources,
});
setNotification({ type: 'success', msg: '✓ Brand book PDF downloaded!' });
} catch (err) {
setNotification({ type: 'error', msg: `Failed to generate PDF: ${err.message}` });
}
setGenerating(false);
};
// ── Sign helpers ─────────────────────────────────────────────────────────────
const updateSign = (key, field, value) =>
setSigns(prev => prev.map(s => s._key === key ? { ...s, [field]: value } : s));
const addSign = () => { setSigns(prev => [...prev, EMPTY_SIGN()]); setExpandedSign(signs.length); };
const removeSign = (key) => setSigns(prev => prev.filter(s => s._key !== key));
const handleSignPhoto = (key, file) => {
updateSign(key, 'photo', file);
updateSign(key, '_photoPreview', URL.createObjectURL(file));
updateSign(key, 'photoPath', ''); // clear old path when new file selected
};
// ── Site map helpers ──────────────────────────────────────────────────────────
const handleSiteMapFile = (file) => {
setSiteMapFile(file);
setSiteMapPath('');
setSiteMapPreview(URL.createObjectURL(file));
};
// ── Survey photo helpers ──────────────────────────────────────────────────────
const handleSitePhotos = (files) => {
const newItems = Array.from(files)
.filter(f => f.type.startsWith('image/'))
.map(file => ({ file, path: '', preview: URL.createObjectURL(file) }));
setPhotoItems(prev => [...prev, ...newItems]);
};
const removeSitePhoto = (i) => setPhotoItems(prev => prev.filter((_, idx) => idx !== i));
// ── Unsaved changes indicator ─────────────────────────────────────────────────
const hasUnsavedPhotos = siteMapFile || signs.some(s => s.photo) || photoItems.some(i => i.file);
// ═══════════════════════════════════════════════════════════════════════════════
// LIST VIEW
// ═══════════════════════════════════════════════════════════════════════════════
if (view === 'list') {
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">
Brand Book
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--accent)', marginLeft: 8, padding: '2px 8px', border: '1px solid var(--accent)', borderRadius: 4 }}>beta</span>
</div>
<div className="page-subtitle">Saved brand books click to edit or add a revision.</div>
</div>
<button className="btn btn-primary btn-sm" onClick={handleNew}>+ New Brand Book</button>
</div>
{loadingBooks ? (
<p style={{ padding: '24px 0', color: 'var(--text-muted)' }}>Loading...</p>
) : savedBooks.length === 0 ? (
<div className="empty-state">
<h3>No brand books yet</h3>
<p>Create your first brand book to get started.</p>
<button className="btn btn-primary" onClick={handleNew} style={{ marginTop: 16 }}>+ New Brand Book</button>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{savedBooks.map(book => (
<BookListItem
key={book.id}
book={book}
onEdit={() => handleLoad(book)}
onRevision={() => handleAddRevision(book)}
onDelete={() => handleDelete(book.id)}
/>
))}
</div>
)}
</Layout>
);
}
// ═══════════════════════════════════════════════════════════════════════════════
// FORM VIEW
// ═══════════════════════════════════════════════════════════════════════════════
return (
<Layout>
<div className="page-header">
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<button
className="btn btn-outline btn-sm"
onClick={() => setView('list')}
style={{ fontSize: 12 }}
> All Brand Books</button>
<div className="page-title" style={{ margin: 0 }}>
{currentId
? `${bookInfo.clientName || 'Brand Book'}${bookInfo.projectName || ''} R${String(bookInfo.revision).padStart(2, '0')}`
: 'New Brand Book'}
</div>
</div>
<div className="page-subtitle" style={{ marginTop: 4 }}>
{currentId ? 'Editing saved brand book' : 'Unsaved — fill in details and save'}
{hasUnsavedPhotos && <span style={{ color: 'var(--accent)', marginLeft: 8 }}>· Unsaved photos</span>}
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-outline btn-sm" onClick={() => { if (window.confirm('Discard changes?')) { resetForm(); setView('list'); } }}>Discard</button>
<button className="btn btn-outline btn-sm" onClick={handleSave} disabled={saving}>
{saving ? 'Saving...' : '💾 Save'}
</button>
<button className="btn btn-primary btn-sm" onClick={handleGenerate} disabled={generating}>
{generating ? 'Generating...' : '⬇ Generate PDF'}
</button>
</div>
</div>
{notification && (
<div className={`notification ${notification.type === 'error' ? 'notification-error' : 'notification-success'}`} style={{ marginBottom: 24 }}>
{notification.msg}
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
{/* ── BRAND BOOK INFO ──────────────────────────────────────────────── */}
<div className="card">
<div className="card-title">Brand Book Info</div>
<div className="grid-2">
<div className="form-group">
<label>Client *</label>
<select value={bookInfo.clientId} onChange={handleClientChange}>
<option value=""> Select client </option>
{clients.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div className="form-group">
<label>Project Name</label>
<input type="text" placeholder="e.g. Bolchoz Sign Solutions 2025" value={bookInfo.projectName} onChange={set('projectName')} />
</div>
</div>
<div className="grid-2">
<div className="form-group">
<label>Site Address</label>
<input type="text" placeholder="e.g. 123 Main St, City, State" value={bookInfo.siteAddress} onChange={set('siteAddress')} />
</div>
<div className="form-group">
<label>Date</label>
<input type="date" value={bookInfo.bookDate} onChange={set('bookDate')} />
</div>
</div>
<div className="grid-2">
<div className="form-group">
<label>Prepared By</label>
<input type="text" placeholder="e.g. John Smith" value={bookInfo.preparedBy} onChange={set('preparedBy')} />
</div>
<div className="form-group">
<label>Revision #</label>
<input
type="number"
min="1"
max="99"
value={parseInt(bookInfo.revision, 10) || 1}
onChange={e => setBookInfo(b => ({ ...b, revision: String(e.target.value).padStart(2, '0') }))}
style={{ width: 100 }}
/>
</div>
</div>
</div>
{/* ── SITE MAP ──────────────────────────────────────────────────────── */}
<div className="card">
<div className="card-title">Site Map</div>
<div className="form-group">
<label>Upload Site Map Image <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional shown on page 2 beside the sign inventory)</span></label>
<SiteMapDropZone
preview={siteMapPreview}
onFile={handleSiteMapFile}
onClear={() => { setSiteMapFile(null); setSiteMapPath(''); setSiteMapPreview(null); }}
inputRef={siteMapRef}
/>
</div>
</div>
{/* ── SIGNS ─────────────────────────────────────────────────────────── */}
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<div className="card-title" style={{ margin: 0 }}>Signs</div>
<button className="btn btn-outline btn-sm" onClick={addSign}>+ Add Sign</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{signs.map((sign, i) => (
<SignCard
key={sign._key}
sign={sign}
index={i}
expanded={expandedSign === i}
onToggle={() => setExpandedSign(expandedSign === i ? null : i)}
onChange={(field, val) => updateSign(sign._key, field, val)}
onPhotoChange={(file) => handleSignPhoto(sign._key, file)}
onRemove={() => removeSign(sign._key)}
canRemove={signs.length > 1}
/>
))}
</div>
<button className="btn btn-outline btn-sm" onClick={addSign} style={{ marginTop: 12 }}>+ Add Sign</button>
</div>
{/* ── SITE PHOTOS ───────────────────────────────────────────────────── */}
<div className="card">
<div className="card-title">Site Photos</div>
<div className="form-group">
<label>General site photos <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(displayed as thumbnails on the last page up to 12 shown)</span></label>
<SitePhotosDropZone
photoItems={photoItems}
onFiles={handleSitePhotos}
onRemove={removeSitePhoto}
inputRef={sitePhotosRef}
/>
</div>
</div>
</div>
{/* Bottom actions */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 24, paddingBottom: 40 }}>
<button className="btn btn-outline" onClick={handleSave} disabled={saving}>
{saving ? 'Saving...' : '💾 Save Brand Book'}
</button>
<button className="btn btn-primary btn-lg" onClick={handleGenerate} disabled={generating}>
{generating ? 'Generating...' : '⬇ Generate Brand Book PDF'}
</button>
</div>
</Layout>
);
}
// ─── Book List Item ───────────────────────────────────────────────────────────
function BookListItem({ book, onEdit, onRevision, onDelete }) {
const signCount = Array.isArray(book.signs) ? book.signs.length : 0;
const date = book.book_date
? new Date(book.book_date + 'T12:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
: null;
const updated = book.updated_at
? new Date(book.updated_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
: null;
return (
<div style={{
padding: '12px 16px',
border: '1px solid var(--border)',
borderRadius: 8,
background: 'var(--card-bg)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
}}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{ fontWeight: 700, fontSize: 14, color: 'var(--text-primary)' }}>{book.client_name}</span>
{book.project_name && (
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}> {book.project_name}</span>
)}
<span style={{
fontSize: 11, fontWeight: 700, padding: '1px 7px',
background: 'var(--accent)', color: '#1a1a1a', borderRadius: 4,
}}>R{String(book.revision || '01').padStart(2, '0')}</span>
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 3, display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{book.site_address && <span>📍 {book.site_address}</span>}
{signCount > 0 && <span>🪧 {signCount} sign{signCount !== 1 ? 's' : ''}</span>}
{date && <span>📅 {date}</span>}
{updated && <span style={{ color: 'var(--text-muted)', opacity: 0.7 }}>Saved {updated}</span>}
</div>
</div>
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
<button className="btn btn-outline btn-sm" onClick={onEdit}>Edit</button>
<button
className="btn btn-outline btn-sm"
onClick={onRevision}
title="Create a new revision based on this one"
style={{ color: 'var(--accent)', borderColor: 'var(--accent)' }}
>+ Revision</button>
<button
type="button"
onClick={onDelete}
style={{ background: 'none', border: 'none', color: 'var(--danger, #dc2626)', cursor: 'pointer', fontSize: 16, padding: '0 4px' }}
></button>
</div>
</div>
);
}
// ─── Sign Card ────────────────────────────────────────────────────────────────
function SignCard({ sign, index, expanded, onToggle, onChange, onPhotoChange, onRemove, canRemove }) {
const photoInputRef = useRef();
const dragCounter = useRef(0);
const [dragging, setDragging] = useState(false);
const handleDragEnter = (e) => { e.preventDefault(); dragCounter.current++; setDragging(true); };
const handleDragLeave = (e) => { e.preventDefault(); dragCounter.current--; if (dragCounter.current === 0) setDragging(false); };
const handleDragOver = (e) => e.preventDefault();
const handleDrop = (e) => {
e.preventDefault(); dragCounter.current = 0; setDragging(false);
const file = e.dataTransfer.files[0];
if (file && file.type.startsWith('image/')) onPhotoChange(file);
};
const summary = [sign.type, sign.location].filter(Boolean).join(' — ') || 'New Sign';
const hasPhoto = sign._photoPreview;
return (
<div style={{ border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden' }}>
<button
type="button"
onClick={onToggle}
style={{
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '10px 14px', background: 'var(--card-bg-2)', border: 'none', cursor: 'pointer',
fontFamily: 'inherit', borderBottom: expanded ? '1px solid var(--border)' : 'none',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 10, fontWeight: 700, padding: '2px 8px', background: 'var(--accent)', color: '#1a1a1a', borderRadius: 4 }}>
#{sign.signNumber || (index + 1)}
</span>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{summary}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{hasPhoto && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>📷</span>}
{sign.photo && <span style={{ fontSize: 11, color: 'var(--accent)' }}>unsaved photo</span>}
{canRemove && (
<span role="button" onClick={e => { e.stopPropagation(); onRemove(); }}
style={{ fontSize: 13, color: 'var(--danger, #dc2626)', padding: '2px 6px', cursor: 'pointer' }}></span>
)}
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{expanded ? '▲' : '▼'}</span>
</div>
</button>
{expanded && (
<div style={{ padding: 16 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 12 }}>
<div className="form-group" style={{ margin: 0 }}>
<label>Sign # / ID</label>
<input type="text" placeholder={`e.g. ${index + 1}`} value={sign.signNumber} onChange={e => onChange('signNumber', e.target.value)} />
</div>
<div className="form-group" style={{ margin: 0 }}>
<label>Sign Type</label>
<input type="text" placeholder="e.g. Monument, Channel Letter, Pylon…" value={sign.type} onChange={e => onChange('type', e.target.value)} />
</div>
<div className="form-group" style={{ margin: 0 }}>
<label>Location</label>
<input type="text" placeholder="e.g. Main entrance, North parking lot" value={sign.location} onChange={e => onChange('location', e.target.value)} />
</div>
<div className="form-group" style={{ margin: 0 }}>
<label>Dimensions (inches)</label>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input type="text" placeholder='Width"' value={sign.width} onChange={e => onChange('width', e.target.value)} style={{ flex: 1 }} />
<span style={{ color: 'var(--text-muted)', fontSize: 12 }}>×</span>
<input type="text" placeholder='Height"' value={sign.height} onChange={e => onChange('height', e.target.value)} style={{ flex: 1 }} />
</div>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12, marginBottom: 12 }}>
<div className="form-group" style={{ margin: 0 }}>
<label>Material <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>placeholder</span></label>
<input type="text" placeholder="e.g. Aluminum, Acrylic" value={sign.material} onChange={e => onChange('material', e.target.value)} />
</div>
<div className="form-group" style={{ margin: 0 }}>
<label>Illumination <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>placeholder</span></label>
<input type="text" placeholder="e.g. LED, None, Neon" value={sign.illumination} onChange={e => onChange('illumination', e.target.value)} />
</div>
<div className="form-group" style={{ margin: 0 }}>
<label>Condition <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>placeholder</span></label>
<input type="text" placeholder="e.g. Good, Fair, Poor" value={sign.condition} onChange={e => onChange('condition', e.target.value)} />
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: 12, marginBottom: 12 }}>
<div className="form-group" style={{ margin: 0 }}>
<label>Mount Type <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>placeholder</span></label>
<input type="text" placeholder="e.g. Wall, Post, Ground" value={sign.mountType} onChange={e => onChange('mountType', e.target.value)} />
</div>
<div className="form-group" style={{ margin: 0 }}>
<label>Notes</label>
<input type="text" placeholder="Any additional observations…" value={sign.notes} onChange={e => onChange('notes', e.target.value)} />
</div>
</div>
<div className="form-group" style={{ margin: 0 }}>
<label>Sign Photo</label>
<div
onDragEnter={handleDragEnter} onDragLeave={handleDragLeave}
onDragOver={handleDragOver} onDrop={handleDrop}
onClick={() => photoInputRef.current?.click()}
style={{
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 8,
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))',
padding: 12, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 12,
transition: 'border-color 0.15s, background 0.15s', minHeight: 60,
}}
>
{sign._photoPreview ? (
<>
<img src={sign._photoPreview} alt="sign" style={{ height: 60, width: 80, objectFit: 'cover', borderRadius: 4, flexShrink: 0 }} />
<div>
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)' }}>
{sign.photo ? sign.photo.name : 'Saved photo'}
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>Click to replace · drag a new photo</div>
</div>
</>
) : (
<div style={{ color: dragging ? 'var(--accent)' : 'var(--text-muted)', fontSize: 13 }}>
{dragging ? '📂 Drop photo here' : '📷 Click to upload or drag & drop a photo'}
</div>
)}
</div>
<input ref={photoInputRef} type="file" accept="image/*" style={{ display: 'none' }}
onChange={e => { const f = e.target.files[0]; if (f) onPhotoChange(f); e.target.value = ''; }} />
</div>
</div>
)}
</div>
);
}
// ─── Site Map Drop Zone ───────────────────────────────────────────────────────
function SiteMapDropZone({ preview, onFile, onClear, inputRef }) {
const dragCounter = useRef(0);
const [dragging, setDragging] = useState(false);
const handleDragEnter = (e) => { e.preventDefault(); dragCounter.current++; setDragging(true); };
const handleDragLeave = (e) => { e.preventDefault(); dragCounter.current--; if (dragCounter.current === 0) setDragging(false); };
const handleDragOver = (e) => e.preventDefault();
const handleDrop = (e) => {
e.preventDefault(); dragCounter.current = 0; setDragging(false);
const file = e.dataTransfer.files[0];
if (file && file.type.startsWith('image/')) onFile(file);
};
if (preview) {
return (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 16 }}>
<img src={preview} alt="site map" style={{ maxHeight: 160, maxWidth: 280, borderRadius: 6, border: '1px solid var(--border)', objectFit: 'contain' }} />
<div>
<button className="btn btn-outline btn-sm" onClick={onClear}>Remove</button>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 8 }}>Click to replace</div>
</div>
</div>
);
}
return (
<>
<div
onDragEnter={handleDragEnter} onDragLeave={handleDragLeave}
onDragOver={handleDragOver} onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
style={{
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 8,
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))',
padding: '24px 16px', textAlign: 'center', cursor: 'pointer',
color: dragging ? 'var(--accent)' : 'var(--text-muted)', fontSize: 13, transition: 'all 0.15s',
}}
>
{dragging ? '📂 Drop site map here' : '🗺 Click to upload or drag & drop a site map image'}
</div>
<input ref={inputRef} type="file" accept="image/*" style={{ display: 'none' }}
onChange={e => { const f = e.target.files[0]; if (f) onFile(f); e.target.value = ''; }} />
</>
);
}
// ─── Survey Photos Drop Zone ──────────────────────────────────────────────────
function SitePhotosDropZone({ photoItems, onFiles, onRemove, inputRef }) {
const dragCounter = useRef(0);
const [dragging, setDragging] = useState(false);
const handleDragEnter = (e) => { e.preventDefault(); dragCounter.current++; setDragging(true); };
const handleDragLeave = (e) => { e.preventDefault(); dragCounter.current--; if (dragCounter.current === 0) setDragging(false); };
const handleDragOver = (e) => e.preventDefault();
const handleDrop = (e) => { e.preventDefault(); dragCounter.current = 0; setDragging(false); onFiles(e.dataTransfer.files); };
return (
<div>
<div
onDragEnter={handleDragEnter} onDragLeave={handleDragLeave}
onDragOver={handleDragOver} onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
style={{
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 8,
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))',
padding: '20px 16px', textAlign: 'center', cursor: 'pointer',
color: dragging ? 'var(--accent)' : 'var(--text-muted)', fontSize: 13,
marginBottom: photoItems.length > 0 ? 12 : 0, transition: 'all 0.15s',
}}
>
{dragging ? '📂 Drop photos here' : '📷 Click to upload or drag & drop photos (select multiple)'}
</div>
<input ref={inputRef} type="file" accept="image/*" multiple style={{ display: 'none' }}
onChange={e => { if (e.target.files.length) { onFiles(e.target.files); e.target.value = ''; } }} />
{photoItems.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{photoItems.map((item, i) => (
<div key={i} style={{ position: 'relative' }}>
<img
src={item.preview}
alt={item.file?.name || `photo ${i + 1}`}
style={{ width: 80, height: 60, objectFit: 'cover', borderRadius: 4, border: '1px solid var(--border)', display: 'block' }}
/>
{item.file && (
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, background: 'rgba(245,165,35,0.8)', fontSize: 8, textAlign: 'center', borderRadius: '0 0 4px 4px', padding: '1px 2px', color: '#1a1a1a', fontWeight: 700 }}>NEW</div>
)}
<button
type="button"
onClick={() => onRemove(i)}
style={{
position: 'absolute', top: -6, right: -6,
background: '#dc2626', border: 'none', borderRadius: '50%',
width: 18, height: 18, fontSize: 10, color: '#fff',
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', lineHeight: 1,
}}
></button>
</div>
))}
<div style={{ fontSize: 11, color: 'var(--text-muted)', alignSelf: 'flex-end', marginLeft: 4 }}>
{photoItems.length} photo{photoItems.length !== 1 ? 's' : ''}
{photoItems.length > 12 && <span style={{ color: 'var(--accent)' }}> (first 12 shown in PDF)</span>}
</div>
</div>
)}
</div>
);
}
+247
View File
@@ -0,0 +1,247 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import Layout from '../../components/Layout';
import LoadingButton from '../../components/LoadingButton';
import { supabase } from '../../lib/supabase';
import { sendEmail } from '../../lib/email';
import { generateSubcontractorPOPDF } from '../../lib/invoice';
const poStatusColor = {
draft: 'not_started',
sent: 'in_progress',
approved: 'client_approved',
ready_to_pay: 'in_progress',
paid: 'client_approved',
cancelled: 'needs_revision',
};
const poStatusLabel = {
draft: 'Draft',
sent: 'Sent',
approved: 'Approved',
ready_to_pay: 'Ready to Pay',
paid: 'Paid',
cancelled: 'Cancelled',
};
const poSelect = '*, profile:profiles!subcontractor_payments_profile_id_fkey(id, name, email), project:projects(id, name, company:companies(name)), items:subcontractor_po_items(*, task:tasks(id, title))';
export default function SubcontractorPODetail() {
const { id } = useParams();
const navigate = useNavigate();
const [po, setPO] = useState(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [generating, setGenerating] = useState(false);
useEffect(() => {
async function load() {
try {
const { data } = await supabase
.from('subcontractor_payments')
.select(poSelect)
.eq('id', id)
.single();
setPO(data || null);
} catch (error) {
console.error('Subcontractor PO load failed:', error);
} finally {
setLoading(false);
}
}
load();
}, [id]);
const updatePO = async (updates, errorLabel = 'Failed to update PO') => {
setSaving(true);
const { data, error } = await supabase
.from('subcontractor_payments')
.update(updates)
.eq('id', id)
.select(poSelect)
.single();
setSaving(false);
if (error) {
alert(`${errorLabel}: ${error.message}`);
return null;
}
setPO(data);
return data;
};
const sendPOEmail = async (sentPO) => {
if (!sentPO?.profile?.email) {
alert('This subcontractor has no email on file.');
return;
}
await sendEmail('subcontractor_po_sent', sentPO.profile.email, {
poNumber: sentPO.po_number || 'Purchase Order',
subcontractorName: sentPO.profile?.name || 'there',
projectName: sentPO.project?.name || 'Subcontractor Work',
companyName: sentPO.project?.company?.name || 'Fourge Branding',
amount: `$${Number(sentPO.amount).toFixed(2)}`,
dueDate: sentPO.due_date ? new Date(sentPO.due_date).toLocaleDateString() : 'Not set',
terms: sentPO.terms || 'Net 15',
scope: sentPO.items?.length
? sentPO.items.map(item => `${item.description} - $${Number(item.amount).toFixed(2)}`).join('\n')
: sentPO.description,
portalUrl: 'https://portal.fourgebranding.com/my-purchase-orders',
});
};
const handleFinalizeSend = async () => {
if (!window.confirm(`Finalize and send ${po.po_number || 'this PO'} to ${po.profile?.email || 'the subcontractor'}?`)) return;
const sentPO = await updatePO({ status: 'sent', sent_at: new Date().toISOString() }, 'Failed to finalize PO');
if (!sentPO) return;
try {
await sendPOEmail(sentPO);
alert('PO email sent successfully.');
} catch (error) {
alert(`PO was finalized, but the email failed: ${error.message || 'unknown error'}`);
}
};
const handleResend = async () => {
if (!window.confirm(`Resend ${po.po_number || 'this PO'} to ${po.profile?.email || 'the subcontractor'}?`)) return;
setSaving(true);
try {
await sendPOEmail(po);
alert('PO email sent successfully.');
} catch (error) {
alert(`Failed to send PO: ${error.message || 'unknown error'}`);
} finally {
setSaving(false);
}
};
const handleReadyToPay = () => updatePO({ status: 'ready_to_pay' }, 'Failed to mark ready to pay');
const handleMarkPaid = () => updatePO({ status: 'paid', paid_at: new Date().toISOString().slice(0, 10) }, 'Failed to mark paid');
const handleReopen = () => updatePO({ status: 'draft', paid_at: null, cancelled_at: null }, 'Failed to reopen PO');
const handleCancel = async () => {
if (!window.confirm(`Cancel ${po.po_number || 'this PO'}?`)) return;
await updatePO({ status: 'cancelled', cancelled_at: new Date().toISOString() }, 'Failed to cancel PO');
};
const handleDelete = async () => {
if (!window.confirm(`Delete ${po.po_number || 'this PO'}? This cannot be undone.`)) return;
setSaving(true);
const { error } = await supabase.from('subcontractor_payments').delete().eq('id', id);
if (error) {
alert(`Failed to delete PO: ${error.message}`);
setSaving(false);
return;
}
navigate('/invoices', { state: { tab: 'subcontractor-po' } });
};
const handleDownload = async () => {
if (generating) return;
setGenerating(true);
try {
await generateSubcontractorPOPDF(po);
} finally {
setGenerating(false);
}
};
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
if (!po) return <Layout><p>Purchase order not found.</p></Layout>;
const sortedItems = (po.items || []).slice().sort((a, b) => Number(a.sort_order || 0) - Number(b.sort_order || 0));
return (
<Layout>
<button className="back-link" onClick={() => navigate('/invoices', { state: { tab: 'subcontractor-po' } })}> Back to Subcontractor PO</button>
<div className="page-header">
<div>
<div className="page-title">{po.po_number || 'Purchase Order'}</div>
</div>
<div className="action-buttons">
<span className={`badge badge-${poStatusColor[po.status] || 'not_started'}`} style={{ fontSize: 13, padding: '6px 14px' }}>
{poStatusLabel[po.status] || po.status}
</span>
<LoadingButton className="btn btn-primary" loading={generating} loadingText="Generating..." onClick={handleDownload}>Download PO</LoadingButton>
{po.status !== 'draft' && po.status !== 'cancelled' && (
<button className="btn btn-outline" onClick={handleResend} disabled={saving}>Resend PO</button>
)}
</div>
</div>
<div className="grid-2" style={{ marginBottom: 24 }}>
<div className="card">
<div className="card-title">Subcontractor</div>
<div style={{ fontSize: 15, fontWeight: 700 }}>{po.profile?.name || 'External Team Member'}</div>
<div style={{ color: 'var(--text-muted)', fontSize: 13, marginTop: 4 }}>{po.profile?.email || 'No email on file'}</div>
</div>
<div className="card">
<div className="card-title">PO Details</div>
<div className="detail-grid" style={{ marginBottom: 0 }}>
<div className="detail-item"><label>PO Date</label><p>{new Date(po.date).toLocaleDateString()}</p></div>
<div className="detail-item"><label>Due Date</label><p>{po.due_date ? new Date(po.due_date).toLocaleDateString() : '—'}</p></div>
<div className="detail-item"><label>Terms</label><p>{po.terms || 'Net 15'}</p></div>
<div className="detail-item"><label>Project</label><p>{po.project?.name || 'No project'}</p></div>
<div className="detail-item"><label>Client</label><p>{po.project?.company?.name || '—'}</p></div>
<div className="detail-item"><label>Total</label><p style={{ fontSize: 18, fontWeight: 700, color: 'var(--accent)' }}>${Number(po.amount).toFixed(2)}</p></div>
{po.paid_at && <div className="detail-item"><label>Paid On</label><p>{new Date(po.paid_at).toLocaleDateString()}</p></div>}
</div>
</div>
</div>
<div className="card" style={{ marginBottom: 24 }}>
<div className="card-title">Line Items</div>
<div className="table-wrapper" style={{ border: 'none' }}>
<table>
<thead>
<tr>
<th>Project</th>
<th>Task</th>
<th>Description</th>
<th style={{ textAlign: 'right' }}>Amount</th>
</tr>
</thead>
<tbody>
{sortedItems.map(item => (
<tr key={item.id}>
<td>{po.project?.name || 'No project'}</td>
<td>{item.task?.title || '—'}</td>
<td>{item.description}</td>
<td style={{ textAlign: 'right', fontWeight: 700 }}>${Number(item.amount).toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '16px 16px 0', borderTop: '1px solid var(--border)', marginTop: 8 }}>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>Total</div>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--accent)' }}>${Number(po.amount).toFixed(2)}</div>
</div>
</div>
</div>
{(po.description || po.notes?.trim()) && (
<div className="card" style={{ marginBottom: 24 }}>
<div className="card-title">Notes</div>
{po.description && <p style={{ fontSize: 14, color: 'var(--text-secondary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{po.description}</p>}
{po.notes?.trim() && <p style={{ fontSize: 14, color: 'var(--text-secondary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{po.notes}</p>}
</div>
)}
<div className="card">
<div className="card-title">Actions</div>
<div className="action-buttons">
{po.status === 'draft' && <button className="btn btn-primary" onClick={handleFinalizeSend} disabled={saving}>Finalize & Send</button>}
{po.status !== 'draft' && po.status !== 'cancelled' && <button className="btn btn-outline" onClick={handleResend} disabled={saving}>Resend PO</button>}
{['sent', 'approved'].includes(po.status) && <button className="btn btn-outline" onClick={handleReadyToPay} disabled={saving}>Mark Ready</button>}
{po.status === 'ready_to_pay' && <button className="btn btn-success" onClick={handleMarkPaid} disabled={saving}>Mark Paid</button>}
{po.status === 'paid' && <button className="btn btn-outline" onClick={handleReopen} disabled={saving}>Reopen</button>}
<LoadingButton className="btn btn-primary" loading={generating} loadingText="Generating..." onClick={handleDownload}>Download PO</LoadingButton>
{!['paid', 'cancelled'].includes(po.status) && <button className="btn btn-outline" onClick={handleCancel} disabled={saving}>Cancel PO</button>}
<button className="btn btn-danger" onClick={handleDelete} disabled={saving}>Delete PO</button>
</div>
</div>
</Layout>
);
}
+312
View File
@@ -0,0 +1,312 @@
import { useRef, useState } from 'react';
import Layout from '../../components/Layout';
import LoadingButton from '../../components/LoadingButton';
import { generateSurveyMakerPdf } from '../../lib/surveyMakerPdf';
const PHOTO_FILE_ACCEPT = 'image/*,.heic,.heif,.avif,.tif,.tiff,.bmp,.webp,.jpeg,.jpg,.png,.gif';
const PHOTO_FILE_EXTENSIONS = new Set(['heic', 'heif', 'avif', 'tif', 'tiff', 'bmp', 'webp', 'jpeg', 'jpg', 'png', 'gif']);
const EMPTY_SIGN = () => ({
id: Math.random().toString(36).slice(2),
signName: '',
measurements: '',
notes: '',
mainPhoto: null,
mainPreview: '',
contextPhoto1: null,
contextPreview1: '',
contextPhoto2: null,
contextPreview2: '',
contextPhoto3: null,
contextPreview3: '',
});
function isPhotoFile(file) {
if (!file) return false;
if (file.type?.startsWith('image/')) return true;
const extension = file.name?.split('.').pop()?.toLowerCase();
return extension ? PHOTO_FILE_EXTENSIONS.has(extension) : false;
}
export default function SurveyMaker() {
const [surveyInfo, setSurveyInfo] = useState({
clientName: '',
projectName: '',
siteAddress: '',
surveyDate: new Date().toISOString().slice(0, 10),
preparedBy: '',
});
const [signs, setSigns] = useState([EMPTY_SIGN()]);
const [generating, setGenerating] = useState(false);
const [notification, setNotification] = useState(null);
const setField = (field) => (event) => {
setSurveyInfo(current => ({ ...current, [field]: event.target.value }));
};
const updateSign = (id, field, value) => {
setSigns(current => current.map(sign => (sign.id === id ? { ...sign, [field]: value } : sign)));
};
const handlePhoto = (id, field, previewField, file) => {
if (!isPhotoFile(file)) return;
updateSign(id, field, file);
updateSign(id, previewField, URL.createObjectURL(file));
};
const addSign = () => {
setSigns(current => [...current, EMPTY_SIGN()]);
};
const removeSign = (id) => {
setSigns(current => current.length === 1 ? current : current.filter(sign => sign.id !== id));
};
const handleGenerate = async () => {
setGenerating(true);
setNotification(null);
try {
await generateSurveyMakerPdf({
...surveyInfo,
signs,
});
setNotification({ type: 'success', msg: '✓ Survey PDF downloaded!' });
} catch (error) {
setNotification({ type: 'error', msg: `Failed to generate PDF: ${error.message}` });
} finally {
setGenerating(false);
}
};
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">Survey Maker</div>
<div className="page-subtitle">Create a survey PDF with sign photos, measurements, and notes.</div>
</div>
<LoadingButton className="btn btn-primary btn-sm" loading={generating} loadingText="Generating..." onClick={handleGenerate}>Generate PDF</LoadingButton>
</div>
{notification && (
<div style={{ marginBottom: 18, color: notification.type === 'error' ? 'var(--danger)' : 'var(--success, #16a34a)', fontSize: 13 }}>
{notification.msg}
</div>
)}
<div style={{ display: 'grid', gap: 24 }}>
<div className="card">
<div className="card-title">Survey Info</div>
<div className="grid-2">
<div className="form-group">
<label>Client Name</label>
<input type="text" value={surveyInfo.clientName} onChange={setField('clientName')} placeholder="e.g. Bolchoz Sign Solutions" />
</div>
<div className="form-group">
<label>Project Name</label>
<input type="text" value={surveyInfo.projectName} onChange={setField('projectName')} placeholder="e.g. Main Street Sign Survey" />
</div>
<div className="form-group">
<label>Survey Date</label>
<input type="date" value={surveyInfo.surveyDate} onChange={setField('surveyDate')} />
</div>
<div className="form-group">
<label>Prepared By</label>
<input type="text" value={surveyInfo.preparedBy} onChange={setField('preparedBy')} placeholder="Your name" />
</div>
</div>
<div className="form-group" style={{ marginBottom: 0 }}>
<label>Site Address</label>
<input type="text" value={surveyInfo.siteAddress} onChange={setField('siteAddress')} placeholder="e.g. 123 Main St, City, State" />
</div>
</div>
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<div className="card-title" style={{ margin: 0 }}>Signs</div>
<button className="btn btn-outline btn-sm" onClick={addSign}>+ Add Sign</button>
</div>
<div style={{ display: 'grid', gap: 14 }}>
{signs.map((sign, index) => (
<SignCard
key={sign.id}
sign={sign}
index={index}
onChange={updateSign}
onPhoto={handlePhoto}
onRemove={removeSign}
canRemove={signs.length > 1}
/>
))}
</div>
</div>
</div>
</Layout>
);
}
function SignCard({ sign, index, onChange, onPhoto, onRemove, canRemove }) {
const mainInputRef = useRef(null);
const contextInputRef1 = useRef(null);
const contextInputRef2 = useRef(null);
const contextInputRef3 = useRef(null);
return (
<div style={{ border: '1px solid var(--border)', borderRadius: 10, padding: 16, display: 'grid', gap: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)' }}>
{sign.signName || `Sign ${index + 1}`}
</div>
{canRemove && (
<button className="btn btn-outline btn-sm" onClick={() => onRemove(sign.id)}>
Remove
</button>
)}
</div>
<div className="grid-2">
<div className="form-group">
<label>Sign Name</label>
<input
type="text"
value={sign.signName}
onChange={(event) => onChange(sign.id, 'signName', event.target.value)}
placeholder={`e.g. Sign ${index + 1}`}
/>
</div>
<div className="form-group">
<label>Main Photo</label>
<PhotoPicker
inputRef={mainInputRef}
preview={sign.mainPreview}
label="Click to upload main sign photo"
onPick={(file) => onPhoto(sign.id, 'mainPhoto', 'mainPreview', file)}
/>
</div>
</div>
<div className="form-group" style={{ marginBottom: 0 }}>
<label>Context Photos</label>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12 }}>
<PhotoPicker
inputRef={contextInputRef1}
preview={sign.contextPreview1}
label="Context photo 1"
small
onPick={(file) => onPhoto(sign.id, 'contextPhoto1', 'contextPreview1', file)}
/>
<PhotoPicker
inputRef={contextInputRef2}
preview={sign.contextPreview2}
label="Context photo 2"
small
onPick={(file) => onPhoto(sign.id, 'contextPhoto2', 'contextPreview2', file)}
/>
<PhotoPicker
inputRef={contextInputRef3}
preview={sign.contextPreview3}
label="Context photo 3"
small
onPick={(file) => onPhoto(sign.id, 'contextPhoto3', 'contextPreview3', file)}
/>
</div>
</div>
<div className="grid-2">
<div className="form-group">
<label>Measurements / Info</label>
<textarea
value={sign.measurements}
onChange={(event) => onChange(sign.id, 'measurements', event.target.value)}
placeholder={'e.g. 48" W x 24" H\nAluminum panel\nMounted to brick facade'}
style={{ minHeight: 110 }}
/>
</div>
<div className="form-group">
<label>Notes</label>
<textarea
value={sign.notes}
onChange={(event) => onChange(sign.id, 'notes', event.target.value)}
placeholder="Additional survey notes..."
style={{ minHeight: 110 }}
/>
</div>
</div>
</div>
);
}
function PhotoPicker({ inputRef, preview, label, onPick, small = false }) {
const dragCounter = useRef(0);
const [dragging, setDragging] = useState(false);
const handleDragEnter = (event) => {
event.preventDefault();
dragCounter.current += 1;
setDragging(true);
};
const handleDragLeave = (event) => {
event.preventDefault();
dragCounter.current -= 1;
if (dragCounter.current === 0) setDragging(false);
};
const handleDragOver = (event) => {
event.preventDefault();
};
const handleDrop = (event) => {
event.preventDefault();
dragCounter.current = 0;
setDragging(false);
const file = event.dataTransfer.files?.[0];
if (isPhotoFile(file)) onPick(file);
};
return (
<>
<div
onClick={() => inputRef.current?.click()}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
style={{
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 8,
minHeight: small ? 110 : 120,
cursor: 'pointer',
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--card-bg-2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
padding: 10,
transition: 'border-color 0.15s, background 0.15s',
}}
>
{preview ? (
<img src={preview} alt={label} style={{ maxHeight: small ? 100 : 150, maxWidth: '100%', objectFit: 'contain', borderRadius: 6 }} />
) : (
<div style={{ fontSize: 13, color: 'var(--text-muted)', textAlign: 'center' }}>
{dragging ? 'Drop photo here' : label}
</div>
)}
</div>
<input
ref={inputRef}
type="file"
accept={PHOTO_FILE_ACCEPT}
style={{ display: 'none' }}
onChange={(event) => {
const file = event.target.files?.[0];
if (isPhotoFile(file)) onPick(file);
event.target.value = '';
}}
/>
</>
);
}
File diff suppressed because it is too large Load Diff
Executable → Regular
+1 -1
View File
@@ -1 +1 @@
v2.78.1 v2.98.2
Executable → Regular
View File
Executable → Regular
View File
Executable → Regular
View File
Executable → Regular
View File
Executable → Regular
View File
Executable → Regular
+1 -1
View File
@@ -1 +1 @@
fix-optimized-search-function operation-ergonomics
Executable → Regular
+1 -1
View File
@@ -1 +1 @@
v1.37.7 v1.48.20
+5
View File
@@ -0,0 +1,5 @@
[functions.send-email]
verify_jwt = false
[functions.create-user]
verify_jwt = false
@@ -0,0 +1,92 @@
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
import Stripe from 'https://esm.sh/stripe@14?target=deno';
const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!, { apiVersion: '2023-10-16' });
const STRIPE_CURRENCY = 'usd';
const STRIPE_CURRENCY_LABEL = 'USD';
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};
serve(async (req) => {
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders });
try {
const { invoice_id, invoice_ref } = await req.json();
const invoiceRef = invoice_ref || invoice_id;
if (!invoiceRef) return new Response(JSON.stringify({ error: 'invoice_ref required' }), { status: 400, headers: corsHeaders });
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
);
let invoice = null;
let error = null;
({ data: invoice, error } = await supabase
.from('invoices')
.select('*')
.eq('id', invoiceRef)
.maybeSingle());
if (!invoice) {
({ data: invoice, error } = await supabase
.from('invoices')
.select('*')
.eq('invoice_number', invoiceRef)
.maybeSingle());
}
if (error || !invoice) return new Response(JSON.stringify({ error: 'Invoice not found' }), { status: 404, headers: corsHeaders });
if (invoice.status === 'paid') return new Response(JSON.stringify({ error: 'Invoice already paid' }), { status: 400, headers: corsHeaders });
const { data: company } = invoice.company_id
? await supabase
.from('companies')
.select('name, email')
.eq('id', invoice.company_id)
.maybeSingle()
: { data: null };
const origin = 'https://portal.fourgebranding.com';
const billToLabel = invoice.bill_to || company?.name;
const session = await stripe.checkout.sessions.create({
// No payment_method_types — Stripe uses whatever is enabled in the dashboard
// (cards, ACH, etc.) without needing to hardcode them here.
mode: 'payment',
customer_email: company?.email || undefined,
line_items: [{
price_data: {
currency: STRIPE_CURRENCY,
product_data: {
name: `Invoice ${invoice.invoice_number} (${STRIPE_CURRENCY_LABEL})`,
description: billToLabel
? `Fourge Branding — ${billToLabel} (${STRIPE_CURRENCY_LABEL})`
: `Fourge Branding invoice charged in ${STRIPE_CURRENCY_LABEL}`,
},
unit_amount: Math.round(Number(invoice.total) * 100),
},
quantity: 1,
}],
// Stamp invoice_id on both the session and the payment intent so the
// webhook can match it whether the payment settles instantly (card) or
// asynchronously (ACH — payment_intent.succeeded fires days later).
metadata: { invoice_id: invoice.id, currency: STRIPE_CURRENCY },
payment_intent_data: {
metadata: { invoice_id: invoice.id },
},
success_url: `${origin}/pay/${encodeURIComponent(invoice.invoice_number)}?success=1`,
cancel_url: `${origin}/pay/${encodeURIComponent(invoice.invoice_number)}?cancelled=1`,
});
return new Response(JSON.stringify({ url: session.url }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
} catch (err) {
console.error(err);
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers: corsHeaders });
}
});
+48 -18
View File
@@ -1,5 +1,4 @@
// deno-lint-ignore-file // deno-lint-ignore-file
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
const corsHeaders = { const corsHeaders = {
@@ -7,17 +6,18 @@ const corsHeaders = {
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
} }
const ok = (body: Record<string, unknown>) => new Response(JSON.stringify(body), { const json = (body: Record<string, unknown>, status: number) =>
status: 200, new Response(JSON.stringify(body), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status,
}) headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
serve(async (req) => { Deno.serve(async (req) => {
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders }) if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
try { try {
const authHeader = req.headers.get('Authorization') ?? '' const authHeader = req.headers.get('Authorization') ?? ''
if (!authHeader) return ok({ error: 'No authorization header' }) if (!authHeader) return json({ error: 'No authorization header' }, 401)
const callerClient = createClient( const callerClient = createClient(
Deno.env.get('SUPABASE_URL') ?? '', Deno.env.get('SUPABASE_URL') ?? '',
@@ -26,16 +26,19 @@ serve(async (req) => {
) )
const { data: userData, error: userError } = await callerClient.auth.getUser() const { data: userData, error: userError } = await callerClient.auth.getUser()
if (userError || !userData?.user) return ok({ error: `Auth failed: ${userError?.message ?? 'no user'}` }) if (userError || !userData?.user) return json({ error: `Auth failed: ${userError?.message ?? 'no user'}` }, 401)
const { data: profile, error: profileError } = await callerClient const { data: profile, error: profileError } = await callerClient
.from('profiles').select('role').eq('id', userData.user.id).single() .from('profiles').select('role').eq('id', userData.user.id).single()
if (profileError) return ok({ error: `Profile error: ${profileError.message}` }) if (profileError) return json({ error: `Profile error: ${profileError.message}` }, 500)
if (profile?.role !== 'team') return ok({ error: 'Forbidden: team only' }) if (profile?.role !== 'team') return json({ error: 'Forbidden: team only' }, 403)
const body = await req.json() const body = await req.json()
const { name, email, password, company_id } = body const { name, email, password, company_id, role: roleParam } = body
if (!name || !email || !password) return ok({ error: 'name, email and password are required' }) if (!name || !email || !password) return json({ error: 'name, email and password are required' }, 400)
const validRoles = ['team', 'client', 'external']
const role = validRoles.includes(roleParam) ? roleParam : 'client'
const admin = createClient( const admin = createClient(
Deno.env.get('SUPABASE_URL') ?? '', Deno.env.get('SUPABASE_URL') ?? '',
@@ -46,20 +49,47 @@ serve(async (req) => {
email, email,
password, password,
email_confirm: true, email_confirm: true,
user_metadata: { name, role: 'client' }, user_metadata: { name, role },
}) })
if (createError) return ok({ error: `Create user failed: ${createError.message}` }) if (createError) return json({ error: `Create user failed: ${createError.message}` }, 500)
if (role === 'client' && company_id && created?.user) {
const { error: profileUpsertError } = await admin
.from('profiles')
.upsert({
id: created.user.id,
name,
email,
role,
company_id,
}, { onConflict: 'id' })
if (profileUpsertError) return json({ error: `User created but profile setup failed: ${profileUpsertError.message}` }, 500)
if (company_id && created?.user) {
const { error: assignError } = await admin const { error: assignError } = await admin
.from('profiles').update({ company_id }).eq('id', created.user.id) .from('profiles').update({ company_id }).eq('id', created.user.id)
if (assignError) return ok({ error: `User created but company assign failed: ${assignError.message}` }) if (assignError) return json({ error: `User created but company assign failed: ${assignError.message}` }, 500)
const { error: membershipError } = await admin
.from('company_members')
.upsert({ company_id, profile_id: created.user.id }, { onConflict: 'company_id,profile_id' })
if (membershipError) return json({ error: `User created but company membership failed: ${membershipError.message}` }, 500)
} else if (role === 'external' && created?.user) {
const { error: profileUpsertError } = await admin
.from('profiles')
.upsert({
id: created.user.id,
name,
email,
role,
company_id: null,
}, { onConflict: 'id' })
if (profileUpsertError) return json({ error: `User created but profile setup failed: ${profileUpsertError.message}` }, 500)
} }
return ok({ success: true }) return json({ success: true }, 200)
} catch (err) { } catch (err) {
return ok({ error: `Unexpected: ${String(err)}` }) return json({ error: `Unexpected: ${String(err)}` }, 500)
} }
}) })
+53
View File
@@ -0,0 +1,53 @@
// deno-lint-ignore-file
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
}
const json = (body: Record<string, unknown>, status: number) =>
new Response(JSON.stringify(body), {
status,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
Deno.serve(async (req) => {
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
try {
const authHeader = req.headers.get('Authorization') ?? ''
if (!authHeader) return json({ error: 'No authorization header' }, 401)
const callerClient = createClient(
Deno.env.get('SUPABASE_URL') ?? '',
Deno.env.get('SUPABASE_ANON_KEY') ?? '',
{ global: { headers: { Authorization: authHeader } } }
)
const { data: userData, error: userError } = await callerClient.auth.getUser()
if (userError || !userData?.user) return json({ error: `Auth failed: ${userError?.message ?? 'no user'}` }, 401)
const { data: profile, error: profileError } = await callerClient
.from('profiles').select('role').eq('id', userData.user.id).single()
if (profileError) return json({ error: `Profile error: ${profileError.message}` }, 500)
if (profile?.role !== 'team') return json({ error: 'Forbidden: team only' }, 403)
const body = await req.json()
const { user_id } = body
if (!user_id) return json({ error: 'user_id is required' }, 400)
const admin = createClient(
Deno.env.get('SUPABASE_URL') ?? '',
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
)
const { error: deleteError } = await admin.auth.admin.deleteUser(user_id)
if (deleteError) return json({ error: `Delete failed: ${deleteError.message}` }, 500)
return json({ success: true }, 200)
} catch (err) {
return json({ error: `Unexpected: ${String(err)}` }, 500)
}
})
@@ -0,0 +1,99 @@
// deno-lint-ignore-file
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
}
const json = (body: Record<string, unknown>, status: number) =>
new Response(JSON.stringify(body), {
status,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
function encodeBase64(bytes: Uint8Array) {
return btoa(String.fromCharCode(...bytes))
}
function decodeBase64(value: string) {
return Uint8Array.from(atob(value), char => char.charCodeAt(0))
}
async function requireTeam(authHeader: string) {
const supabaseUrl = Deno.env.get('SUPABASE_URL') ?? ''
const supabaseKey = Deno.env.get('SUPABASE_ANON_KEY') || Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') || ''
const callerClient = createClient(
supabaseUrl,
supabaseKey,
{ global: { headers: { Authorization: authHeader } } }
)
const { data: userData, error: userError } = await callerClient.auth.getUser()
if (userError || !userData?.user) return { ok: false, error: `Auth failed: ${userError?.message ?? 'no user'}`, status: 401 }
const { data: profile, error: profileError } = await callerClient
.from('profiles').select('role').eq('id', userData.user.id).single()
if (profileError) return { ok: false, error: `Profile error: ${profileError.message}`, status: 500 }
if (profile?.role !== 'team') return { ok: false, error: 'Forbidden: team only', status: 403 }
return { ok: true }
}
async function getKey() {
const secret = Deno.env.get('PASSWORD_VAULT_KEY') ?? ''
if (!secret) throw new Error('PASSWORD_VAULT_KEY is not configured.')
const rawKey = decodeBase64(secret)
return crypto.subtle.importKey('raw', rawKey, 'AES-GCM', false, ['encrypt', 'decrypt'])
}
Deno.serve(async (req) => {
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
try {
const authHeader = req.headers.get('Authorization') ?? ''
if (!authHeader) return json({ error: 'No authorization header' }, 401)
const auth = await requireTeam(authHeader)
if (!auth.ok) return json({ error: auth.error as string }, auth.status as number)
const body = await req.json()
const action = body?.action
const key = await getKey()
if (action === 'encrypt') {
const plaintext = String(body?.plaintext ?? '')
if (!plaintext) return json({ error: 'plaintext required' }, 400)
const iv = crypto.getRandomValues(new Uint8Array(12))
const encoded = new TextEncoder().encode(plaintext)
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoded)
return json({
ciphertext: encodeBase64(new Uint8Array(ciphertext)),
iv: encodeBase64(iv),
}, 200)
}
if (action === 'decrypt') {
const ciphertext = String(body?.ciphertext ?? '')
const ivValue = String(body?.iv ?? '')
if (!ciphertext || !ivValue) return json({ error: 'ciphertext and iv required' }, 400)
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: decodeBase64(ivValue) },
key,
decodeBase64(ciphertext),
)
return json({ plaintext: new TextDecoder().decode(decrypted) }, 200)
}
return json({ error: 'Invalid action' }, 400)
} catch (err) {
return json({ error: `Unexpected: ${String(err)}` }, 500)
}
})
@@ -0,0 +1,70 @@
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};
serve(async (req) => {
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders });
try {
const { invoice_id, invoice_ref } = await req.json();
const invoiceRef = invoice_ref || invoice_id;
if (!invoiceRef) {
return new Response(JSON.stringify({ error: 'invoice_ref required' }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
);
let invoice = null;
let error = null;
({ data: invoice, error } = await supabase
.from('invoices')
.select('id, invoice_number, invoice_date, due_date, total, status, bill_to, company_id')
.eq('id', invoiceRef)
.maybeSingle());
if (!invoice) {
({ data: invoice, error } = await supabase
.from('invoices')
.select('id, invoice_number, invoice_date, due_date, total, status, bill_to, company_id')
.eq('invoice_number', invoiceRef)
.maybeSingle());
}
if (error || !invoice) {
return new Response(JSON.stringify({ error: 'Invoice not found' }), {
status: 404,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const { data: company } = invoice.company_id
? await supabase
.from('companies')
.select('name, email')
.eq('id', invoice.company_id)
.maybeSingle()
: { data: null };
return new Response(JSON.stringify({ invoice: { ...invoice, companies: company || null } }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
} catch (err) {
console.error(err);
return new Response(JSON.stringify({ error: err.message }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
});
+378 -35
View File
@@ -1,102 +1,445 @@
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'; import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
const RESEND_API_KEY = Deno.env.get('RESEND_API_KEY'); const RESEND_API_KEY = Deno.env.get('RESEND_API_KEY');
const SUPABASE_URL = Deno.env.get('SUPABASE_URL');
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY');
const FROM = 'Fourge Branding <hello@fourgebranding.com>'; const FROM = 'Fourge Branding <hello@fourgebranding.com>';
const TEAM_EMAILS = [
'krao@fourgebranding.com',
'smarsell@fourgebranding.com',
'swebb@fourgebranding.com',
'twebb@fourgebranding.com',
];
const ALLOWED_TYPES = ['new_request', 'sent_to_client', 'revision_submitted', 'client_approved', 'invoice_sent', 'receipt_sent', 'subcontractor_po_sent'] as const;
type EmailType = typeof ALLOWED_TYPES[number];
// Types that only team members may trigger
const TEAM_ONLY_TYPES: EmailType[] = ['sent_to_client', 'invoice_sent', 'receipt_sent', 'subcontractor_po_sent'];
const corsHeaders = { const corsHeaders = {
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
}; };
/** Escape a value for safe insertion into HTML. */
const esc = (s: unknown): string => {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
};
/** Escape and convert newlines to <br> for multi-line text fields. */
const escMultiline = (s: unknown): string =>
esc(s).replace(/\n/g, '<br>');
/** Validate a string field: must be a non-empty string within max length. */
const requireStr = (val: unknown, max = 500): string => {
if (typeof val !== 'string' || !val.trim()) throw new Error('Missing required field');
if (val.length > max) throw new Error('Field exceeds maximum length');
return val.trim();
};
/** Validate an optional string field. Returns '' if absent. */
const optStr = (val: unknown, max = 500): string => {
if (val == null || val === '') return '';
if (typeof val !== 'string') throw new Error('Invalid field type');
if (val.length > max) throw new Error('Field exceeds maximum length');
return val.trim();
};
/** Basic UUID format check. */
const isUuid = (v: unknown): v is string =>
typeof v === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v);
/** Basic email format check. */
const isEmail = (v: unknown): v is string =>
typeof v === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) && v.length <= 254;
const parseJsonSafe = async (res: Response) => {
const text = await res.text();
if (!text) return null;
try {
return JSON.parse(text);
} catch {
return { raw: text };
}
};
const isBase64 = (value: unknown): value is string =>
typeof value === 'string' && value.length <= 10_000_000 && /^[A-Za-z0-9+/=]+$/.test(value);
const safeFilename = (value: unknown): string => {
const filename = requireStr(value, 180);
if (!/^[a-zA-Z0-9._ -]+\.pdf$/i.test(filename)) throw new Error('Invalid attachment filename');
return filename;
};
serve(async (req) => { serve(async (req) => {
if (req.method === 'OPTIONS') { if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders }); return new Response('ok', { headers: corsHeaders });
} }
try { const respond = (body: unknown, status: number) =>
const { type, to, data } = await req.json(); new Response(JSON.stringify(body), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status,
});
try {
if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) {
return respond({ error: 'Missing Supabase environment variables' }, 500);
}
if (!RESEND_API_KEY) {
return respond({ error: 'Missing RESEND_API_KEY' }, 500);
}
// ── 1. Authentication ────────────────────────────────────────────────────
const authHeader = req.headers.get('Authorization') ?? '';
const accessToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
if (!accessToken) {
return respond({ error: 'Unauthorized' }, 401);
}
// Use service role client to validate the token — works with ES256 JWTs
const adminClient = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
const { data: userData, error: authError } = await adminClient.auth.getUser(accessToken);
if (authError || !userData?.user) {
return respond({ error: `Auth failed: ${authError?.message ?? 'no user'}` }, 401);
}
// ── 2. Role lookup ───────────────────────────────────────────────────────
const { data: profile } = await adminClient
.from('profiles')
.select('role')
.eq('id', userData.user.id)
.single();
const role: string = profile?.role ?? '';
if (!['team', 'client', 'external'].includes(role)) {
return respond({ error: 'Forbidden' }, 403);
}
// ── 3. Parse and validate body ───────────────────────────────────────────
const body = await req.json();
const { type, to, data, attachments } = body;
if (!ALLOWED_TYPES.includes(type)) {
return respond({ error: 'Invalid email type' }, 400);
}
if (TEAM_ONLY_TYPES.includes(type) && role !== 'team') {
return respond({ error: 'Forbidden' }, 403);
}
const safeAttachments = Array.isArray(attachments)
? attachments.slice(0, 3).map((attachment) => {
const filename = safeFilename(attachment?.filename);
if (!isBase64(attachment?.content)) throw new Error('Invalid attachment content');
return { filename, content: attachment.content };
})
: [];
// ── 4. Build email per type (with validated + escaped fields) ────────────
let subject = ''; let subject = '';
let html = ''; let html = '';
if (type === 'new_request') { if (type === 'new_request') {
subject = `New Request: ${data.serviceType}${data.clientName}`; const serviceType = requireStr(data?.serviceType);
const clientName = requireStr(data?.clientName);
const clientEmail = requireStr(data?.clientEmail);
const projectName = requireStr(data?.projectName);
const description = requireStr(data?.description, 10000);
const company = optStr(data?.company);
const deadline = optStr(data?.deadline);
const taskId = data?.taskId;
if (!isUuid(taskId)) throw new Error('Invalid taskId');
subject = `New Request: ${esc(serviceType)}${esc(clientName)}`;
html = ` html = `
<h2>New Request Received</h2> <h2>New Request Received</h2>
<p><strong>From:</strong> ${data.clientName} (${data.clientEmail})</p> <p><strong>From:</strong> ${esc(clientName)} (${esc(clientEmail)})</p>
<p><strong>Company:</strong> ${data.company || '—'}</p> <p><strong>Company:</strong> ${esc(company) || '—'}</p>
<p><strong>Service:</strong> ${data.serviceType}</p> <p><strong>Service:</strong> ${esc(serviceType)}</p>
<p><strong>Project:</strong> ${data.projectName}</p> <p><strong>Project:</strong> ${esc(projectName)}</p>
${data.deadline ? `<p><strong>Deadline:</strong> ${data.deadline}</p>` : ''} ${deadline ? `<p><strong>Deadline:</strong> ${esc(deadline)}</p>` : ''}
<hr /> <hr />
<p><strong>Description:</strong></p> <p><strong>Description:</strong></p>
<p>${data.description}</p> <p>${escMultiline(description)}</p>
<br /> <br />
<a href="https://portal.fourgebranding.com/tasks/${data.taskId}" style="background:#F5A523;color:#1a1a1a;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">View Job</a> <a href="https://portal.fourgebranding.com/tasks/${esc(taskId)}" style="background:#F5A523;color:#1a1a1a;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">View Job</a>
`; `;
} }
else if (type === 'sent_to_client') { else if (type === 'sent_to_client') {
subject = `Your ${data.serviceType} is ready for review!`; const serviceType = requireStr(data?.serviceType);
const clientFirstName = requireStr(data?.clientFirstName);
const projectName = requireStr(data?.projectName);
const message = optStr(data?.message, 5000);
const taskId = data?.taskId;
if (!isUuid(taskId)) throw new Error('Invalid taskId');
subject = `Your ${esc(serviceType)} is ready for review!`;
html = ` html = `
<h2>Hi ${data.clientFirstName},</h2> <h2>Hi ${esc(clientFirstName)},</h2>
<p>Your <strong>${data.serviceType}</strong> for <strong>${data.projectName}</strong> is ready for review.</p> <p>Your <strong>${esc(serviceType)}</strong> for <strong>${esc(projectName)}</strong> is ready for review.</p>
${data.message ? `<p>${data.message}</p>` : ''} ${message ? `<p>${escMultiline(message)}</p>` : ''}
<p>Please log in to the portal to review and approve or request changes.</p> <p>Please log in to the portal to review and approve or request changes.</p>
<br /> <br />
<a href="https://portal.fourgebranding.com/my-requests/${data.taskId}" style="background:#F5A523;color:#1a1a1a;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Review Your Work</a> <a href="https://portal.fourgebranding.com/my-requests/${esc(taskId)}" style="background:#F5A523;color:#1a1a1a;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Review Your Work</a>
<br /><br /> <br /><br />
<p style="color:#666;font-size:13px;">— The Fourge Branding Team</p> <p style="color:#666;font-size:13px;">— The Fourge Branding Team</p>
`; `;
} }
else if (type === 'revision_submitted') { else if (type === 'revision_submitted') {
subject = `Revision Request: ${data.serviceType}${data.clientName}`; const serviceType = requireStr(data?.serviceType);
const clientName = requireStr(data?.clientName);
const projectName = requireStr(data?.projectName);
const version = requireStr(data?.version);
const description = requireStr(data?.description, 10000);
const deadline = optStr(data?.deadline);
const taskId = data?.taskId;
if (!isUuid(taskId)) throw new Error('Invalid taskId');
subject = `Revision Request: ${esc(serviceType)}${esc(clientName)}`;
html = ` html = `
<h2>Revision Requested</h2> <h2>Revision Requested</h2>
<p><strong>From:</strong> ${data.clientName}</p> <p><strong>From:</strong> ${esc(clientName)}</p>
<p><strong>Job:</strong> ${data.serviceType}${data.projectName}</p> <p><strong>Job:</strong> ${esc(serviceType)}${esc(projectName)}</p>
<p><strong>New Version:</strong> ${data.version}</p> <p><strong>New Version:</strong> ${esc(version)}</p>
${data.deadline ? `<p><strong>New Deadline:</strong> ${data.deadline}</p>` : ''} ${deadline ? `<p><strong>New Deadline:</strong> ${esc(deadline)}</p>` : ''}
<hr /> <hr />
<p><strong>Requested changes:</strong></p> <p><strong>Requested changes:</strong></p>
<p>${data.description}</p> <p>${escMultiline(description)}</p>
<br /> <br />
<a href="https://portal.fourgebranding.com/tasks/${data.taskId}" style="background:#F5A523;color:#1a1a1a;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">View Job</a> <a href="https://portal.fourgebranding.com/tasks/${esc(taskId)}" style="background:#F5A523;color:#1a1a1a;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">View Job</a>
`; `;
} }
else if (type === 'client_approved') { else if (type === 'client_approved') {
subject = `Approved! ${data.serviceType}${data.clientName}`; const serviceType = requireStr(data?.serviceType);
const clientName = requireStr(data?.clientName);
const projectName = requireStr(data?.projectName);
const taskId = data?.taskId;
if (!isUuid(taskId)) throw new Error('Invalid taskId');
subject = `Approved! ${esc(serviceType)}${esc(clientName)}`;
html = ` html = `
<h2>Job Approved ✓</h2> <h2>Job Approved ✓</h2>
<p><strong>${data.clientName}</strong> has approved <strong>${data.serviceType}</strong> for <strong>${data.projectName}</strong>.</p> <p><strong>${esc(clientName)}</strong> has approved <strong>${esc(serviceType)}</strong> for <strong>${esc(projectName)}</strong>.</p>
<p>This job is now complete.</p> <p>This job is now complete.</p>
<br /> <br />
<a href="https://portal.fourgebranding.com/tasks/${data.taskId}" style="background:#F5A523;color:#1a1a1a;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">View Job</a> <a href="https://portal.fourgebranding.com/tasks/${esc(taskId)}" style="background:#F5A523;color:#1a1a1a;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">View Job</a>
`; `;
} }
else if (type === 'invoice_sent') {
const invoiceNumber = requireStr(data?.invoiceNumber);
const billTo = requireStr(data?.billTo);
const total = requireStr(data?.total);
const dueDate = requireStr(data?.dueDate);
const payUrl = requireStr(data?.payUrl);
const notes = optStr(data?.notes, 2000);
subject = `Invoice ${esc(invoiceNumber)} from Fourge Branding — ${esc(total)} due ${esc(dueDate)}`;
html = `
<div style="font-family:sans-serif;max-width:520px;margin:0 auto;color:#1a1a1a;">
<div style="background:#141414;padding:20px 28px;border-radius:8px 8px 0 0;">
<img src="https://portal.fourgebranding.com/fourge-logo.png" alt="Fourge Branding" style="height:28px;" />
</div>
<div style="background:#fff;padding:28px;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 8px 8px;">
<h2 style="margin:0 0 8px;font-size:20px;">Hi ${esc(billTo)},</h2>
<p style="color:#555;margin:0 0 24px;">Please find your invoice from Fourge Branding below.</p>
<table style="width:100%;border-collapse:collapse;margin-bottom:24px;">
<tr style="background:#f9f9f9;">
<td style="padding:10px 14px;font-size:13px;color:#666;">Invoice #</td>
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(invoiceNumber)}</td>
</tr>
<tr>
<td style="padding:10px 14px;font-size:13px;color:#666;">Amount Due</td>
<td style="padding:10px 14px;font-size:18px;font-weight:700;color:#141414;text-align:right;">${esc(total)}</td>
</tr>
<tr style="background:#f9f9f9;">
<td style="padding:10px 14px;font-size:13px;color:#666;">Due Date</td>
<td style="padding:10px 14px;font-size:13px;font-weight:600;text-align:right;">${esc(dueDate)}</td>
</tr>
</table>
${notes ? `<p style="background:#f9f9f9;padding:12px 14px;border-radius:6px;font-size:13px;color:#555;margin-bottom:24px;">${escMultiline(notes)}</p>` : ''}
<a href="${esc(payUrl)}" style="display:block;background:#141414;color:#fff;text-align:center;padding:14px;border-radius:8px;text-decoration:none;font-weight:700;font-size:16px;margin-bottom:20px;">Pay Now — ${esc(total)}</a>
<p style="font-size:12px;color:#999;text-align:center;margin:0;">
Secured by Stripe · Charged in USD<br/>
Questions? <a href="mailto:hello@fourgebranding.com" style="color:#555;">hello@fourgebranding.com</a>
</p>
</div>
</div>
`;
}
else if (type === 'receipt_sent') {
const invoiceNumber = requireStr(data?.invoiceNumber);
const billTo = requireStr(data?.billTo);
const total = requireStr(data?.total);
const paidDate = requireStr(data?.paidDate);
subject = `Payment Receipt — ${esc(invoiceNumber)} — Thank you!`;
html = `
<div style="font-family:sans-serif;max-width:520px;margin:0 auto;color:#1a1a1a;">
<div style="background:#141414;padding:20px 28px;border-radius:8px 8px 0 0;">
<img src="https://portal.fourgebranding.com/fourge-logo.png" alt="Fourge Branding" style="height:28px;" />
</div>
<div style="background:#fff;padding:28px;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 8px 8px;">
<div style="text-align:center;margin-bottom:20px;">
<div style="display:inline-block;background:#16a34a;color:#fff;font-weight:700;font-size:13px;padding:6px 16px;border-radius:20px;letter-spacing:0.5px;">✓ PAYMENT RECEIVED</div>
</div>
<h2 style="margin:0 0 8px;font-size:20px;">Hi ${esc(billTo)},</h2>
<p style="color:#555;margin:0 0 24px;">Thank you — your payment has been received. Here's your receipt.</p>
<table style="width:100%;border-collapse:collapse;margin-bottom:24px;">
<tr style="background:#f9f9f9;">
<td style="padding:10px 14px;font-size:13px;color:#666;">Invoice #</td>
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(invoiceNumber)}</td>
</tr>
<tr>
<td style="padding:10px 14px;font-size:13px;color:#666;">Amount Paid</td>
<td style="padding:10px 14px;font-size:18px;font-weight:700;color:#16a34a;text-align:right;">${esc(total)}</td>
</tr>
<tr style="background:#f9f9f9;">
<td style="padding:10px 14px;font-size:13px;color:#666;">Payment Date</td>
<td style="padding:10px 14px;font-size:13px;font-weight:600;text-align:right;">${esc(paidDate)}</td>
</tr>
</table>
<p style="font-size:12px;color:#999;text-align:center;margin:0;">
Questions? <a href="mailto:hello@fourgebranding.com" style="color:#555;">hello@fourgebranding.com</a>
</p>
</div>
</div>
`;
}
else if (type === 'subcontractor_po_sent') {
const poNumber = requireStr(data?.poNumber);
const subcontractorName = requireStr(data?.subcontractorName);
const projectName = requireStr(data?.projectName);
const companyName = requireStr(data?.companyName);
const amount = requireStr(data?.amount);
const dueDate = requireStr(data?.dueDate);
const terms = requireStr(data?.terms);
const scope = requireStr(data?.scope, 10000);
const portalUrl = requireStr(data?.portalUrl);
subject = `Purchase Order ${esc(poNumber)} from Fourge Branding — ${esc(amount)}`;
html = `
<div style="font-family:sans-serif;max-width:560px;margin:0 auto;color:#1a1a1a;">
<div style="background:#141414;padding:20px 28px;border-radius:8px 8px 0 0;">
<img src="https://portal.fourgebranding.com/fourge-logo.png" alt="Fourge Branding" style="height:28px;" />
</div>
<div style="background:#fff;padding:28px;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 8px 8px;">
<h2 style="margin:0 0 8px;font-size:20px;">Hi ${esc(subcontractorName)},</h2>
<p style="color:#555;margin:0 0 24px;">A subcontractor purchase order is ready for your review.</p>
<table style="width:100%;border-collapse:collapse;margin-bottom:24px;">
<tr style="background:#f9f9f9;">
<td style="padding:10px 14px;font-size:13px;color:#666;">PO #</td>
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(poNumber)}</td>
</tr>
<tr>
<td style="padding:10px 14px;font-size:13px;color:#666;">Project</td>
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(projectName)}</td>
</tr>
<tr style="background:#f9f9f9;">
<td style="padding:10px 14px;font-size:13px;color:#666;">Company</td>
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(companyName)}</td>
</tr>
<tr>
<td style="padding:10px 14px;font-size:13px;color:#666;">Amount</td>
<td style="padding:10px 14px;font-size:18px;font-weight:700;color:#141414;text-align:right;">${esc(amount)}</td>
</tr>
<tr style="background:#f9f9f9;">
<td style="padding:10px 14px;font-size:13px;color:#666;">Due</td>
<td style="padding:10px 14px;font-size:13px;font-weight:600;text-align:right;">${esc(dueDate)}</td>
</tr>
<tr>
<td style="padding:10px 14px;font-size:13px;color:#666;">Terms</td>
<td style="padding:10px 14px;font-size:13px;font-weight:600;text-align:right;">${esc(terms)}</td>
</tr>
</table>
<div style="background:#f9f9f9;padding:12px 14px;border-radius:6px;font-size:13px;color:#555;margin-bottom:24px;">
<strong>Scope</strong><br />
${escMultiline(scope)}
</div>
<a href="${esc(portalUrl)}" style="display:block;background:#141414;color:#fff;text-align:center;padding:14px;border-radius:8px;text-decoration:none;font-weight:700;font-size:16px;margin-bottom:20px;">Review and Approve PO</a>
<p style="font-size:12px;color:#999;text-align:center;margin:0;">
Questions? <a href="mailto:hello@fourgebranding.com" style="color:#555;">hello@fourgebranding.com</a>
</p>
</div>
</div>
`;
}
// ── 5. Resolve recipients ────────────────────────────────────────────────
const teamTypes = ['new_request', 'revision_submitted', 'client_approved'];
let recipients: string[];
let cc: string[] | undefined;
if (teamTypes.includes(type)) {
recipients = TEAM_EMAILS;
} else {
// Validate caller-supplied recipient list
const toList = Array.isArray(to) ? to : [to];
recipients = [...new Set(toList.map(val => typeof val === 'string' ? val.trim() : val).filter(isEmail))];
if (recipients.length === 0) throw new Error('No valid recipient emails');
if (recipients.length > 20) throw new Error('Too many recipients');
const senderEmail = optStr(data?.senderEmail);
if (senderEmail && isEmail(senderEmail)) cc = [senderEmail];
if (type === 'invoice_sent') {
cc = [...new Set([...(cc || []), 'hello@fourgebranding.com'])];
}
}
// ── 6. Send via Resend ───────────────────────────────────────────────────
const payload: Record<string, unknown> = { from: FROM, to: recipients, subject, html };
if (cc) payload.cc = cc;
if (safeAttachments.length) payload.attachments = safeAttachments;
const res = await fetch('https://api.resend.com/emails', { const res = await fetch('https://api.resend.com/emails', {
method: 'POST', method: 'POST',
headers: { headers: {
'Authorization': `Bearer ${RESEND_API_KEY}`, 'Authorization': `Bearer ${RESEND_API_KEY}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ from: FROM, to, subject, html }), body: JSON.stringify(payload),
}); });
const result = await res.json(); const result = await parseJsonSafe(res);
if (!res.ok) {
const message =
(result && typeof result === 'object' && 'message' in result && typeof result.message === 'string' && result.message)
|| (result && typeof result === 'object' && 'error' in result && typeof result.error === 'string' && result.error)
|| `Resend request failed with status ${res.status}`;
console.error('Resend API error:', { status: res.status, result });
return respond({ error: message, resend_status: res.status, resend_body: result }, 400);
}
return new Response(JSON.stringify(result), { return respond(result, 200);
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: res.ok ? 200 : 400,
});
} catch (err) { } catch (err) {
return new Response(JSON.stringify({ error: err.message }), { console.error('send-email failed:', err);
headers: { ...corsHeaders, 'Content-Type': 'application/json' }, return respond({ error: (err as Error).message }, 500);
status: 500,
});
} }
}); });
+41 -27
View File
@@ -17,38 +17,52 @@ serve(async (req) => {
return new Response(`Webhook Error: ${err.message}`, { status: 400 }); return new Response(`Webhook Error: ${err.message}`, { status: 400 });
} }
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
);
// Helper: retrieve fee from a payment intent and mark invoice paid
async function markPaid(paymentIntentId: string, invoice_id: string) {
let stripe_fee: number | null = null;
try {
const pi = await stripe.paymentIntents.retrieve(paymentIntentId, {
expand: ['latest_charge.balance_transaction'],
});
const charge = pi.latest_charge as Stripe.Charge | null;
const balanceTx = charge?.balance_transaction as Stripe.BalanceTransaction | null;
if (balanceTx?.fee != null) stripe_fee = balanceTx.fee / 100;
} catch (err) {
console.error('Failed to retrieve Stripe fee:', err.message);
}
const updateData: Record<string, unknown> = {
status: 'paid',
paid_at: new Date().toISOString(),
};
if (stripe_fee !== null) updateData.stripe_fee = stripe_fee;
await supabase.from('invoices').update(updateData).eq('id', invoice_id);
}
// Card payments: session completes with payment_status = 'paid' immediately
if (event.type === 'checkout.session.completed') { if (event.type === 'checkout.session.completed') {
const session = event.data.object as Stripe.Checkout.Session; const session = event.data.object as Stripe.Checkout.Session;
const invoice_id = session.metadata?.invoice_id; const invoice_id = session.metadata?.invoice_id;
const paymentIntentId = session.payment_intent as string | null;
// Only mark paid here for instant payment methods (card).
// ACH sessions complete with payment_status 'unpaid' — let payment_intent.succeeded handle those.
if (invoice_id && paymentIntentId && session.payment_status === 'paid') {
await markPaid(paymentIntentId, invoice_id);
}
}
// ACH / async payment methods: payment_intent.succeeded fires when money actually settles
if (event.type === 'payment_intent.succeeded') {
const pi = event.data.object as Stripe.PaymentIntent;
const invoice_id = pi.metadata?.invoice_id;
if (invoice_id) { if (invoice_id) {
const supabase = createClient( await markPaid(pi.id, invoice_id);
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
);
// Retrieve the Stripe processing fee from the balance transaction
let stripe_fee: number | null = null;
try {
const paymentIntentId = session.payment_intent as string;
if (paymentIntentId) {
const paymentIntent = await stripe.paymentIntents.retrieve(paymentIntentId, {
expand: ['latest_charge.balance_transaction'],
});
const charge = paymentIntent.latest_charge as Stripe.Charge | null;
const balanceTx = charge?.balance_transaction as Stripe.BalanceTransaction | null;
if (balanceTx?.fee != null) {
stripe_fee = balanceTx.fee / 100;
}
}
} catch (err) {
console.error('Failed to retrieve Stripe fee:', err.message);
}
const updateData: Record<string, unknown> = { status: 'paid' };
if (stripe_fee !== null) updateData.stripe_fee = stripe_fee;
await supabase.from('invoices').update(updateData).eq('id', invoice_id);
} }
} }
@@ -0,0 +1,12 @@
create or replace function public.get_database_size_bytes()
returns bigint
language sql
security definer
set search_path = public
as $$
select pg_database_size(current_database());
$$;
revoke all on function public.get_database_size_bytes() from public;
grant execute on function public.get_database_size_bytes() to authenticated;
grant execute on function public.get_database_size_bytes() to service_role;
@@ -0,0 +1,22 @@
create table if not exists public.server_status_overrides (
id boolean primary key default true,
supabase_egress_bytes bigint,
vercel_fast_data_transfer_bytes bigint,
vercel_edge_requests bigint,
vercel_function_invocations bigint,
vercel_active_cpu_hours numeric(10,4),
updated_at timestamptz not null default now()
);
alter table public.server_status_overrides enable row level security;
drop policy if exists "Team all server_status_overrides" on public.server_status_overrides;
create policy "Team all server_status_overrides"
on public.server_status_overrides
for all
using (get_my_role() = 'team')
with check (get_my_role() = 'team');
insert into public.server_status_overrides (id)
values (true)
on conflict (id) do nothing;
@@ -0,0 +1,24 @@
with affected_tasks as (
select s.task_id
from public.submissions s
group by s.task_id
having min(s.version_number) filter (where s.type <> 'amendment') = 1
and count(*) filter (where s.type <> 'amendment' and s.version_number = 0) = 0
)
update public.submissions s
set version_number = s.version_number - 1
from affected_tasks a
where s.task_id = a.task_id;
with corrected_versions as (
select
s.task_id,
coalesce(max(s.version_number) filter (where s.type <> 'amendment'), 0) as corrected_current_version
from public.submissions s
group by s.task_id
)
update public.tasks t
set current_version = cv.corrected_current_version
from corrected_versions cv
where t.id = cv.task_id
and t.current_version is distinct from cv.corrected_current_version;
@@ -0,0 +1,14 @@
update public.submissions
set service_type = 'Other'
where coalesce(service_type, '') not in (
'Logo Design',
'Brand Identity',
'Brand Guidelines',
'Brand Book',
'Social Media Graphics',
'Print Design',
'Business Cards',
'Packaging Design',
'Web Design',
'Other'
);
@@ -0,0 +1 @@
alter table public.invoices add column if not exists bill_to text;
@@ -0,0 +1,17 @@
insert into storage.buckets (id, name, public)
select 'fourge-files', 'fourge-files', false
where not exists (
select 1 from storage.buckets where id = 'fourge-files'
);
create policy "Team reads fourge files storage" on storage.objects
for select to authenticated
using (bucket_id = 'fourge-files' and get_my_role() = 'team');
create policy "Team inserts fourge files storage" on storage.objects
for insert to authenticated
with check (bucket_id = 'fourge-files' and get_my_role() = 'team');
create policy "Team deletes fourge files storage" on storage.objects
for delete to authenticated
using (bucket_id = 'fourge-files' and get_my_role() = 'team');
@@ -1,9 +1,3 @@
-- ============================================================
-- Migration: Add external role and project_members table
-- Run this in Supabase → SQL Editor → Run
-- ============================================================
-- 1. Update profiles.role check constraint to include 'external'
do $$ do $$
declare declare
cname text; cname text;
@@ -13,7 +7,8 @@ begin
where table_schema = 'public' where table_schema = 'public'
and table_name = 'profiles' and table_name = 'profiles'
and constraint_type = 'CHECK' and constraint_type = 'CHECK'
and constraint_name ilike '%role%'; and constraint_name = 'profiles_role_check';
if cname is not null then if cname is not null then
execute 'alter table public.profiles drop constraint ' || quote_ident(cname); execute 'alter table public.profiles drop constraint ' || quote_ident(cname);
end if; end if;
@@ -23,48 +18,51 @@ $$;
alter table public.profiles alter table public.profiles
add constraint profiles_role_check check (role in ('team', 'client', 'external')); add constraint profiles_role_check check (role in ('team', 'client', 'external'));
-- 2. project_members table create table if not exists public.project_members (
create table public.project_members (
id uuid default gen_random_uuid() primary key, id uuid default gen_random_uuid() primary key,
project_id uuid references public.projects(id) on delete cascade not null, project_id uuid references public.projects(id) on delete cascade not null,
profile_id uuid references public.profiles(id) on delete cascade not null, profile_id uuid references public.profiles(id) on delete cascade not null,
created_at timestamptz default now() not null, created_at timestamptz default now() not null,
unique(project_id, profile_id) unique(project_id, profile_id)
); );
alter table public.project_members enable row level security; alter table public.project_members enable row level security;
-- 3. Helper function
create or replace function public.is_external() create or replace function public.is_external()
returns boolean as $$ returns boolean as $$
select get_my_role() = 'external'; select get_my_role() = 'external';
$$ language sql security definer stable; $$ language sql security definer stable;
-- 4. RLS: project_members drop policy if exists "Team all project_members" on public.project_members;
create policy "Team all project_members" on public.project_members create policy "Team all project_members" on public.project_members
for all using (get_my_role() = 'team'); for all using (get_my_role() = 'team');
drop policy if exists "External reads own memberships" on public.project_members;
create policy "External reads own memberships" on public.project_members create policy "External reads own memberships" on public.project_members
for select using (profile_id = auth.uid()); for select using (profile_id = auth.uid());
-- 5. RLS: projects (external reads assigned only) drop policy if exists "External reads assigned projects" on public.projects;
create policy "External reads assigned projects" on public.projects create policy "External reads assigned projects" on public.projects
for select using ( for select using (
get_my_role() = 'external' and get_my_role() = 'external' and
id in (select project_id from public.project_members where profile_id = auth.uid()) id in (select project_id from public.project_members where profile_id = auth.uid())
); );
-- 6. RLS: tasks (external reads + updates assigned projects) drop policy if exists "External reads assigned tasks" on public.tasks;
create policy "External reads assigned tasks" on public.tasks create policy "External reads assigned tasks" on public.tasks
for select using ( for select using (
get_my_role() = 'external' and get_my_role() = 'external' and
project_id in (select project_id from public.project_members where profile_id = auth.uid()) project_id in (select project_id from public.project_members where profile_id = auth.uid())
); );
drop policy if exists "External updates assigned tasks" on public.tasks;
create policy "External updates assigned tasks" on public.tasks create policy "External updates assigned tasks" on public.tasks
for update using ( for update using (
get_my_role() = 'external' and get_my_role() = 'external' and
project_id in (select project_id from public.project_members where profile_id = auth.uid()) project_id in (select project_id from public.project_members where profile_id = auth.uid())
); );
-- 7. RLS: submissions drop policy if exists "External reads assigned submissions" on public.submissions;
create policy "External reads assigned submissions" on public.submissions create policy "External reads assigned submissions" on public.submissions
for select using ( for select using (
get_my_role() = 'external' and get_my_role() = 'external' and
@@ -74,12 +72,14 @@ create policy "External reads assigned submissions" on public.submissions
where pm.profile_id = auth.uid() where pm.profile_id = auth.uid()
) )
); );
drop policy if exists "External inserts submissions" on public.submissions;
create policy "External inserts submissions" on public.submissions create policy "External inserts submissions" on public.submissions
for insert with check ( for insert with check (
get_my_role() = 'external' and submitted_by = auth.uid() get_my_role() = 'external' and submitted_by = auth.uid()
); );
-- 8. RLS: submission_files drop policy if exists "External reads assigned submission_files" on public.submission_files;
create policy "External reads assigned submission_files" on public.submission_files create policy "External reads assigned submission_files" on public.submission_files
for select using ( for select using (
get_my_role() = 'external' and get_my_role() = 'external' and
@@ -90,10 +90,12 @@ create policy "External reads assigned submission_files" on public.submission_fi
where pm.profile_id = auth.uid() where pm.profile_id = auth.uid()
) )
); );
drop policy if exists "External inserts submission_files" on public.submission_files;
create policy "External inserts submission_files" on public.submission_files create policy "External inserts submission_files" on public.submission_files
for insert with check (get_my_role() = 'external'); for insert with check (get_my_role() = 'external');
-- 9. RLS: deliveries (read only) drop policy if exists "External reads assigned deliveries" on public.deliveries;
create policy "External reads assigned deliveries" on public.deliveries create policy "External reads assigned deliveries" on public.deliveries
for select using ( for select using (
get_my_role() = 'external' and get_my_role() = 'external' and
@@ -105,7 +107,7 @@ create policy "External reads assigned deliveries" on public.deliveries
) )
); );
-- 10. RLS: delivery_files (read only) drop policy if exists "External reads assigned delivery_files" on public.delivery_files;
create policy "External reads assigned delivery_files" on public.delivery_files create policy "External reads assigned delivery_files" on public.delivery_files
for select using ( for select using (
get_my_role() = 'external' and get_my_role() = 'external' and
@@ -117,7 +119,3 @@ create policy "External reads assigned delivery_files" on public.delivery_files
where pm.profile_id = auth.uid() where pm.profile_id = auth.uid()
) )
); );
-- 11. RLS: profiles (external reads own profile only — already covered by existing policy)
-- "Own profile select" policy already handles this with: id = auth.uid()
-- No additional policy needed.
@@ -0,0 +1,23 @@
alter table public.submissions
add column if not exists revision_type text;
do $$
begin
if not exists (
select 1
from pg_constraint
where conname = 'submissions_revision_type_check'
and conrelid = 'public.submissions'::regclass
) then
alter table public.submissions
add constraint submissions_revision_type_check
check (revision_type in ('fourge_error', 'client_revision'));
end if;
end;
$$;
alter table public.submissions
add column if not exists invoiced boolean not null default false;
alter table public.invoice_items
add column if not exists submission_id uuid references public.submissions(id) on delete set null;
@@ -0,0 +1,28 @@
drop policy if exists "Client inserts submission_files" on public.submission_files;
drop policy if exists "External inserts submission_files" on public.submission_files;
create policy "Client inserts submission_files" on public.submission_files
for insert with check (
get_my_role() = 'client'
and submission_id in (
select s.id
from public.submissions s
join public.tasks t on t.id = s.task_id
join public.projects p on p.id = t.project_id
where p.company_id = get_my_company_id()
and s.submitted_by = auth.uid()
)
);
create policy "External inserts submission_files" on public.submission_files
for insert with check (
get_my_role() = 'external'
and submission_id in (
select s.id
from public.submissions s
join public.tasks t on t.id = s.task_id
join public.project_members pm on pm.project_id = t.project_id
where pm.profile_id = auth.uid()
and s.submitted_by = auth.uid()
)
);
@@ -0,0 +1,10 @@
drop policy if exists "Own profile update" on public.profiles;
create policy "Own profile update" on public.profiles
for update
using (id = auth.uid())
with check (
id = auth.uid()
and role = (select role from public.profiles where id = auth.uid())
and company_id is not distinct from (select company_id from public.profiles where id = auth.uid())
);
@@ -0,0 +1,118 @@
drop policy if exists "Auth users upload to submissions" on storage.objects;
drop policy if exists "Auth users read submissions" on storage.objects;
drop policy if exists "Team upload deliveries" on storage.objects;
drop policy if exists "Auth users read deliveries" on storage.objects;
drop policy if exists "Team reads submissions storage" on storage.objects;
create policy "Team reads submissions storage" on storage.objects
for select to authenticated
using (bucket_id = 'submissions' and get_my_role() = 'team');
drop policy if exists "Client reads submissions storage" on storage.objects;
create policy "Client reads submissions storage" on storage.objects
for select to authenticated
using (
bucket_id = 'submissions'
and get_my_role() = 'client'
and split_part(name, '/', 1) in (
select t.id::text
from public.tasks t
join public.projects p on p.id = t.project_id
where p.company_id = get_my_company_id()
)
);
drop policy if exists "External reads submissions storage" on storage.objects;
create policy "External reads submissions storage" on storage.objects
for select to authenticated
using (
bucket_id = 'submissions'
and get_my_role() = 'external'
and split_part(name, '/', 1) in (
select t.id::text
from public.tasks t
join public.project_members pm on pm.project_id = t.project_id
where pm.profile_id = auth.uid()
)
);
drop policy if exists "Team inserts submissions storage" on storage.objects;
create policy "Team inserts submissions storage" on storage.objects
for insert to authenticated
with check (bucket_id = 'submissions' and get_my_role() = 'team');
drop policy if exists "Client inserts submissions storage" on storage.objects;
create policy "Client inserts submissions storage" on storage.objects
for insert to authenticated
with check (
bucket_id = 'submissions'
and get_my_role() = 'client'
and split_part(name, '/', 1) in (
select t.id::text
from public.tasks t
join public.projects p on p.id = t.project_id
where p.company_id = get_my_company_id()
)
);
drop policy if exists "External inserts submissions storage" on storage.objects;
create policy "External inserts submissions storage" on storage.objects
for insert to authenticated
with check (
bucket_id = 'submissions'
and get_my_role() = 'external'
and split_part(name, '/', 1) in (
select t.id::text
from public.tasks t
join public.project_members pm on pm.project_id = t.project_id
where pm.profile_id = auth.uid()
)
);
drop policy if exists "Team deletes submissions storage" on storage.objects;
create policy "Team deletes submissions storage" on storage.objects
for delete to authenticated
using (bucket_id = 'submissions' and get_my_role() = 'team');
drop policy if exists "Team reads deliveries storage" on storage.objects;
create policy "Team reads deliveries storage" on storage.objects
for select to authenticated
using (bucket_id = 'deliveries' and get_my_role() = 'team');
drop policy if exists "Client reads deliveries storage" on storage.objects;
create policy "Client reads deliveries storage" on storage.objects
for select to authenticated
using (
bucket_id = 'deliveries'
and get_my_role() = 'client'
and split_part(name, '/', 1) in (
select t.id::text
from public.tasks t
join public.projects p on p.id = t.project_id
where p.company_id = get_my_company_id()
)
);
drop policy if exists "External reads deliveries storage" on storage.objects;
create policy "External reads deliveries storage" on storage.objects
for select to authenticated
using (
bucket_id = 'deliveries'
and get_my_role() = 'external'
and split_part(name, '/', 1) in (
select t.id::text
from public.tasks t
join public.project_members pm on pm.project_id = t.project_id
where pm.profile_id = auth.uid()
)
);
drop policy if exists "Team inserts deliveries storage" on storage.objects;
create policy "Team inserts deliveries storage" on storage.objects
for insert to authenticated
with check (bucket_id = 'deliveries' and get_my_role() = 'team');
drop policy if exists "Team deletes deliveries storage" on storage.objects;
create policy "Team deletes deliveries storage" on storage.objects
for delete to authenticated
using (bucket_id = 'deliveries' and get_my_role() = 'team');
@@ -0,0 +1,11 @@
drop policy if exists "Client updates own company" on public.companies;
create policy "Client updates own company" on public.companies
for update
using (id = get_my_company_id())
with check (id = get_my_company_id());
drop policy if exists "Client updates own company projects" on public.projects;
create policy "Client updates own company projects" on public.projects
for update
using (company_id = get_my_company_id())
with check (company_id = get_my_company_id());
@@ -0,0 +1,4 @@
create policy "Team updates fourge files storage" on storage.objects
for update to authenticated
using (bucket_id = 'fourge-files' and get_my_role() = 'team')
with check (bucket_id = 'fourge-files' and get_my_role() = 'team');
@@ -0,0 +1,18 @@
create table if not exists public.fourge_passwords (
id uuid default gen_random_uuid() primary key,
service_name text not null,
service_url text default '',
username text not null default '',
encrypted_password text not null,
password_iv text not null,
notes text default '',
created_by uuid references public.profiles(id) on delete set null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
alter table public.fourge_passwords enable row level security;
create policy "Team all fourge_passwords" on public.fourge_passwords
for all using (get_my_role() = 'team')
with check (get_my_role() = 'team');
@@ -0,0 +1,14 @@
create table public.expenses (
id uuid default gen_random_uuid() primary key,
date date default current_date not null,
description text not null,
category text not null default 'Other',
amount numeric(10,2) not null,
notes text default '',
receipt_path text,
receipt_name text,
created_by uuid references public.profiles(id) on delete set null,
created_at timestamptz default now() not null
);
alter table public.expenses enable row level security;
create policy "Team all expenses" on public.expenses for all using (get_my_role() = 'team');
@@ -0,0 +1 @@
alter table public.invoices add column if not exists paid_at timestamptz;
@@ -0,0 +1,2 @@
alter table public.expenses add column if not exists receipt_path text;
alter table public.expenses add column if not exists receipt_name text;
@@ -0,0 +1,22 @@
alter table public.expenses
add column if not exists receipt_path text,
add column if not exists receipt_name text;
insert into storage.buckets (id, name, public)
values ('expense-receipts', 'expense-receipts', false)
on conflict (id) do nothing;
drop policy if exists "Team reads expense receipts storage" on storage.objects;
drop policy if exists "Team inserts expense receipts storage" on storage.objects;
drop policy if exists "Team updates expense receipts storage" on storage.objects;
drop policy if exists "Team deletes expense receipts storage" on storage.objects;
create policy "Team reads expense receipts storage" on storage.objects
for select to authenticated using (bucket_id = 'expense-receipts' and get_my_role() = 'team');
create policy "Team inserts expense receipts storage" on storage.objects
for insert to authenticated with check (bucket_id = 'expense-receipts' and get_my_role() = 'team');
create policy "Team updates expense receipts storage" on storage.objects
for update to authenticated using (bucket_id = 'expense-receipts' and get_my_role() = 'team')
with check (bucket_id = 'expense-receipts' and get_my_role() = 'team');
create policy "Team deletes expense receipts storage" on storage.objects
for delete to authenticated using (bucket_id = 'expense-receipts' and get_my_role() = 'team');
@@ -0,0 +1,153 @@
create table if not exists public.company_members (
id uuid default gen_random_uuid() primary key,
company_id uuid references public.companies(id) on delete cascade not null,
profile_id uuid references public.profiles(id) on delete cascade not null,
created_at timestamptz default now() not null,
unique(company_id, profile_id)
);
alter table public.company_members enable row level security;
insert into public.company_members (company_id, profile_id)
select company_id, id
from public.profiles
where company_id is not null
on conflict (company_id, profile_id) do nothing;
create or replace function public.has_company_access(company uuid)
returns boolean as $$
select exists (
select 1
from public.profiles p
where p.id = auth.uid()
and (
p.company_id = company
or exists (
select 1
from public.company_members cm
where cm.profile_id = auth.uid()
and cm.company_id = company
)
)
);
$$ language sql security definer stable;
drop policy if exists "Team all company_members" on public.company_members;
drop policy if exists "Users read own company memberships" on public.company_members;
create policy "Team all company_members" on public.company_members
for all using (get_my_role() = 'team') with check (get_my_role() = 'team');
create policy "Users read own company memberships" on public.company_members
for select using (profile_id = auth.uid());
drop policy if exists "Client reads own company" on public.companies;
drop policy if exists "Client updates own company" on public.companies;
create policy "Client reads assigned companies" on public.companies
for select using (has_company_access(id));
create policy "Client updates primary company" on public.companies
for update using (id = get_my_company_id()) with check (id = get_my_company_id());
drop policy if exists "Client reads company projects" on public.projects;
drop policy if exists "Client inserts company projects" on public.projects;
drop policy if exists "Client updates own company projects" on public.projects;
create policy "Client reads assigned company projects" on public.projects
for select using (has_company_access(company_id));
create policy "Client inserts assigned company projects" on public.projects
for insert with check (get_my_role() = 'client' and has_company_access(company_id));
create policy "Client updates assigned company projects" on public.projects
for update using (get_my_role() = 'client' and has_company_access(company_id))
with check (get_my_role() = 'client' and has_company_access(company_id));
drop policy if exists "Client reads company tasks" on public.tasks;
drop policy if exists "Client insert task" on public.tasks;
drop policy if exists "Client updates company tasks" on public.tasks;
create policy "Client reads assigned company tasks" on public.tasks for select using (
project_id in (select id from public.projects where has_company_access(company_id))
);
create policy "Client inserts assigned company tasks" on public.tasks for insert with check (
get_my_role() = 'client'
and project_id in (select id from public.projects where has_company_access(company_id))
);
create policy "Client updates assigned company tasks" on public.tasks
for update
using (
get_my_role() = 'client'
and project_id in (select id from public.projects where has_company_access(company_id))
)
with check (
get_my_role() = 'client'
and project_id in (select id from public.projects where has_company_access(company_id))
);
drop policy if exists "Client reads company submissions" on public.submissions;
drop policy if exists "Client inserts submissions" on public.submissions;
create policy "Client reads assigned company submissions" on public.submissions for select using (
task_id in (
select t.id from public.tasks t
join public.projects p on p.id = t.project_id
where has_company_access(p.company_id)
)
);
create policy "Client inserts assigned company submissions" on public.submissions for insert with check (
get_my_role() = 'client'
and submitted_by = auth.uid()
and task_id in (
select t.id from public.tasks t
join public.projects p on p.id = t.project_id
where has_company_access(p.company_id)
)
);
drop policy if exists "Client reads company submission_files" on public.submission_files;
drop policy if exists "Client inserts submission_files" on public.submission_files;
create policy "Client reads assigned company submission_files" on public.submission_files for select using (
submission_id in (
select s.id from public.submissions s
join public.tasks t on t.id = s.task_id
join public.projects p on p.id = t.project_id
where has_company_access(p.company_id)
)
);
create policy "Client inserts assigned company submission_files" on public.submission_files for insert with check (
get_my_role() = 'client'
and submission_id in (
select s.id from public.submissions s
join public.tasks t on t.id = s.task_id
join public.projects p on p.id = t.project_id
where has_company_access(p.company_id)
and s.submitted_by = auth.uid()
)
);
drop policy if exists "Client reads company deliveries" on public.deliveries;
create policy "Client reads assigned company deliveries" on public.deliveries for select using (
submission_id in (
select s.id from public.submissions s
join public.tasks t on t.id = s.task_id
join public.projects p on p.id = t.project_id
where has_company_access(p.company_id)
)
);
drop policy if exists "Client reads company delivery_files" on public.delivery_files;
create policy "Client reads assigned company delivery_files" on public.delivery_files for select using (
delivery_id in (
select d.id from public.deliveries d
join public.submissions s on s.id = d.submission_id
join public.tasks t on t.id = s.task_id
join public.projects p on p.id = t.project_id
where has_company_access(p.company_id)
)
);
drop policy if exists "Client reads own company prices" on public.company_prices;
create policy "Client reads assigned company prices" on public.company_prices
for select using (has_company_access(company_id));
drop policy if exists "Client reads company invoices" on public.invoices;
create policy "Client reads assigned company invoices" on public.invoices
for select using (has_company_access(company_id));
drop policy if exists "Client reads company invoice_items" on public.invoice_items;
create policy "Client reads assigned company invoice_items" on public.invoice_items for select using (
invoice_id in (select id from public.invoices where has_company_access(company_id))
);
@@ -0,0 +1,2 @@
alter table public.invoices
add column if not exists invoice_email text;
@@ -0,0 +1,18 @@
create table if not exists public.subcontractor_payments (
id uuid default gen_random_uuid() primary key,
profile_id uuid references public.profiles(id) on delete set null,
date date default current_date not null,
description text not null,
amount numeric(10,2) not null,
status text not null default 'pending' check (status in ('pending', 'paid')),
paid_at date,
notes text default '',
created_by uuid references public.profiles(id) on delete set null,
created_at timestamptz default now() not null
);
alter table public.subcontractor_payments enable row level security;
drop policy if exists "Team all subcontractor_payments" on public.subcontractor_payments;
create policy "Team all subcontractor_payments" on public.subcontractor_payments
for all using (get_my_role() = 'team') with check (get_my_role() = 'team');
@@ -0,0 +1,9 @@
delete from public.company_members cm
using public.profiles p
where p.id = cm.profile_id
and p.role = 'external';
update public.profiles
set company_id = null
where role = 'external'
and company_id is not null;
@@ -0,0 +1,52 @@
alter table public.subcontractor_payments
add column if not exists po_number text,
add column if not exists project_id uuid references public.projects(id) on delete set null,
add column if not exists due_date date,
add column if not exists terms text default 'Net 15',
add column if not exists sent_at timestamptz,
add column if not exists approved_at timestamptz,
add column if not exists cancelled_at timestamptz;
alter table public.subcontractor_payments
drop constraint if exists subcontractor_payments_status_check;
update public.subcontractor_payments
set status = 'ready_to_pay'
where status = 'pending';
alter table public.subcontractor_payments
add constraint subcontractor_payments_status_check
check (status in ('draft', 'sent', 'approved', 'ready_to_pay', 'paid', 'cancelled'));
with numbered as (
select
id,
'PO-' || to_char(coalesce(created_at, now()), 'YYYY') || '-' || lpad((row_number() over (order by created_at, id))::text, 4, '0') as generated_po_number
from public.subcontractor_payments
where po_number is null
)
update public.subcontractor_payments sp
set po_number = numbered.generated_po_number
from numbered
where sp.id = numbered.id;
create unique index if not exists subcontractor_payments_po_number_key
on public.subcontractor_payments(po_number)
where po_number is not null;
drop policy if exists "External reads own subcontractor purchase orders" on public.subcontractor_payments;
create policy "External reads own subcontractor purchase orders" on public.subcontractor_payments
for select using (
get_my_role() = 'external'
and profile_id = auth.uid()
);
drop policy if exists "External updates own subcontractor purchase orders" on public.subcontractor_payments;
create policy "External updates own subcontractor purchase orders" on public.subcontractor_payments
for update using (
get_my_role() = 'external'
and profile_id = auth.uid()
) with check (
get_my_role() = 'external'
and profile_id = auth.uid()
);
@@ -0,0 +1,33 @@
create table if not exists public.subcontractor_po_items (
id uuid default gen_random_uuid() primary key,
po_id uuid references public.subcontractor_payments(id) on delete cascade not null,
task_id uuid references public.tasks(id) on delete set null,
description text not null,
amount numeric(10,2) not null,
sort_order integer default 0 not null,
created_at timestamptz default now() not null
);
alter table public.subcontractor_po_items enable row level security;
drop policy if exists "Team all subcontractor_po_items" on public.subcontractor_po_items;
create policy "Team all subcontractor_po_items" on public.subcontractor_po_items
for all using (get_my_role() = 'team') with check (get_my_role() = 'team');
drop policy if exists "External reads own subcontractor_po_items" on public.subcontractor_po_items;
create policy "External reads own subcontractor_po_items" on public.subcontractor_po_items
for select using (
get_my_role() = 'external'
and po_id in (
select id from public.subcontractor_payments
where profile_id = auth.uid()
)
);
insert into public.subcontractor_po_items (po_id, task_id, description, amount, sort_order)
select id, null, description, amount, 0
from public.subcontractor_payments sp
where not exists (
select 1 from public.subcontractor_po_items item
where item.po_id = sp.id
);

Some files were not shown because too many files have changed in this diff Show More