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:
@@ -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 *)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,3 +23,4 @@ dist-ssr
|
|||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
.vercel
|
.vercel
|
||||||
|
supabase/.temp
|
||||||
|
|||||||
@@ -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
@@ -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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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: [
|
||||||
|
|||||||
@@ -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
Generated
+114
@@ -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",
|
||||||
|
|||||||
@@ -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
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 302 B |
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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; }
|
||||||
|
|||||||
@@ -0,0 +1,839 @@
|
|||||||
|
import JSZip from 'jszip';
|
||||||
|
import { supabase } from './supabase';
|
||||||
|
import { getBrandBookStoragePaths, getCompanyLogoStoragePath } from './deleteHelpers';
|
||||||
|
|
||||||
|
const ARCHIVE_VERSION = 1;
|
||||||
|
const FILE_BATCH_SIZE = 100;
|
||||||
|
const ROW_BATCH_SIZE = 100;
|
||||||
|
|
||||||
|
function sanitizeFilename(value) {
|
||||||
|
return (value || 'archive').replace(/[^a-z0-9._ -]/gi, '_').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportProgress(onProgress, message) {
|
||||||
|
if (typeof onProgress === 'function') onProgress(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerDownload(blob, filename) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function chunk(list, size) {
|
||||||
|
const chunks = [];
|
||||||
|
for (let i = 0; i < list.length; i += size) chunks.push(list.slice(i, i + size));
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueValues(values) {
|
||||||
|
return [...new Set((values || []).filter(Boolean))];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectByValues(table, column, values, select = '*') {
|
||||||
|
const results = [];
|
||||||
|
for (const batch of chunk(values, FILE_BATCH_SIZE)) {
|
||||||
|
const { data, error } = await supabase.from(table).select(select).in(column, batch);
|
||||||
|
if (error) throw error;
|
||||||
|
results.push(...(data || []));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertRows(table, rows) {
|
||||||
|
if (!rows?.length) return;
|
||||||
|
for (const batch of chunk(rows, ROW_BATCH_SIZE)) {
|
||||||
|
const { error } = await supabase.from(table).insert(batch);
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchStorageBlob(bucket, path) {
|
||||||
|
const { data, error } = await supabase.storage.from(bucket).download(path);
|
||||||
|
if (error) throw new Error(`Failed to download ${bucket}/${path}: ${error.message}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fillZipWithFiles(zip, fileRefs, onProgress) {
|
||||||
|
if (!fileRefs.length) {
|
||||||
|
reportProgress(onProgress, 'No files to download');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < fileRefs.length; i += 1) {
|
||||||
|
const fileRef = fileRefs[i];
|
||||||
|
reportProgress(onProgress, `Downloading files ${i + 1}/${fileRefs.length}`);
|
||||||
|
const blob = await fetchStorageBlob(fileRef.bucket, fileRef.path);
|
||||||
|
zip.file(fileRef.zipPath, blob);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBrandBookFileRefs(books) {
|
||||||
|
return (books || []).flatMap(book =>
|
||||||
|
getBrandBookStoragePaths(book).map(path => ({
|
||||||
|
bucket: 'brand-books',
|
||||||
|
path,
|
||||||
|
zipPath: `storage/brand-books/${path}`,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSubmissionFileRefs(submissionFiles) {
|
||||||
|
return (submissionFiles || [])
|
||||||
|
.filter(file => file.storage_path)
|
||||||
|
.map(file => ({
|
||||||
|
bucket: 'submissions',
|
||||||
|
path: file.storage_path,
|
||||||
|
zipPath: `storage/submissions/${file.storage_path}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDeliveryFileRefs(deliveryFiles) {
|
||||||
|
return (deliveryFiles || [])
|
||||||
|
.filter(file => file.storage_path)
|
||||||
|
.map(file => ({
|
||||||
|
bucket: 'deliveries',
|
||||||
|
path: file.storage_path,
|
||||||
|
zipPath: `storage/deliveries/${file.storage_path}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCompanyLogoFileRefs(company, brandBooks) {
|
||||||
|
const urls = uniqueValues([
|
||||||
|
company?.client_logo_url,
|
||||||
|
...(brandBooks || []).map(book => book.client_logo_url),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return urls
|
||||||
|
.map(url => getCompanyLogoStoragePath(url))
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(path => ({
|
||||||
|
bucket: 'company-logos',
|
||||||
|
path,
|
||||||
|
zipPath: `storage/company-logos/${path}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeFileRefs(fileRefs) {
|
||||||
|
const seen = new Set();
|
||||||
|
return fileRefs.filter(ref => {
|
||||||
|
const key = `${ref.bucket}:${ref.path}`;
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectCompanySnapshot(companyId) {
|
||||||
|
const [
|
||||||
|
{ data: company, error: companyError },
|
||||||
|
{ data: companyPrices, error: pricesError },
|
||||||
|
{ data: projects, error: projectsError },
|
||||||
|
{ data: invoices, error: invoicesError },
|
||||||
|
{ data: brandBooks, error: brandBooksError },
|
||||||
|
{ data: companyProfiles, error: profilesError },
|
||||||
|
] = await Promise.all([
|
||||||
|
supabase.from('companies').select('*').eq('id', companyId).single(),
|
||||||
|
supabase.from('company_prices').select('*').eq('company_id', companyId),
|
||||||
|
supabase.from('projects').select('*').eq('company_id', companyId),
|
||||||
|
supabase.from('invoices').select('*').eq('company_id', companyId),
|
||||||
|
supabase.from('brand_books').select('*').eq('client_id', companyId),
|
||||||
|
supabase.from('profiles').select('id, name, email, role, company_id').eq('company_id', companyId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (companyError) throw companyError;
|
||||||
|
if (pricesError) throw pricesError;
|
||||||
|
if (projectsError) throw projectsError;
|
||||||
|
if (invoicesError) throw invoicesError;
|
||||||
|
if (brandBooksError) throw brandBooksError;
|
||||||
|
if (profilesError) throw profilesError;
|
||||||
|
if (!company) throw new Error('Company not found.');
|
||||||
|
|
||||||
|
const projectIds = (projects || []).map(project => project.id);
|
||||||
|
const invoiceIds = (invoices || []).map(invoice => invoice.id);
|
||||||
|
const [tasks, projectMembers, invoiceItems] = await Promise.all([
|
||||||
|
projectIds.length ? selectByValues('tasks', 'project_id', projectIds) : Promise.resolve([]),
|
||||||
|
projectIds.length ? selectByValues('project_members', 'project_id', projectIds) : Promise.resolve([]),
|
||||||
|
invoiceIds.length ? selectByValues('invoice_items', 'invoice_id', invoiceIds) : Promise.resolve([]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const taskIds = tasks.map(task => task.id);
|
||||||
|
const submissions = taskIds.length ? await selectByValues('submissions', 'task_id', taskIds) : [];
|
||||||
|
const submissionIds = submissions.map(submission => submission.id);
|
||||||
|
const [submissionFiles, deliveries] = await Promise.all([
|
||||||
|
submissionIds.length ? selectByValues('submission_files', 'submission_id', submissionIds) : Promise.resolve([]),
|
||||||
|
submissionIds.length ? selectByValues('deliveries', 'submission_id', submissionIds) : Promise.resolve([]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const deliveryIds = deliveries.map(delivery => delivery.id);
|
||||||
|
const deliveryFiles = deliveryIds.length ? await selectByValues('delivery_files', 'delivery_id', deliveryIds) : [];
|
||||||
|
|
||||||
|
const referencedProfileIds = uniqueValues([
|
||||||
|
...companyProfiles.map(profile => profile.id),
|
||||||
|
...projectMembers.map(member => member.profile_id),
|
||||||
|
...tasks.map(task => task.assigned_to),
|
||||||
|
...submissions.map(submission => submission.submitted_by),
|
||||||
|
...invoices.map(invoice => invoice.created_by),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const referencedProfiles = referencedProfileIds.length
|
||||||
|
? await selectByValues('profiles', 'id', referencedProfileIds, 'id, name, email, role, company_id')
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const fileRefs = dedupeFileRefs([
|
||||||
|
...buildSubmissionFileRefs(submissionFiles),
|
||||||
|
...buildDeliveryFileRefs(deliveryFiles),
|
||||||
|
...buildBrandBookFileRefs(brandBooks),
|
||||||
|
...buildCompanyLogoFileRefs(company, brandBooks),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
company,
|
||||||
|
companyPrices,
|
||||||
|
projects,
|
||||||
|
tasks,
|
||||||
|
submissions,
|
||||||
|
submissionFiles,
|
||||||
|
deliveries,
|
||||||
|
deliveryFiles,
|
||||||
|
invoices,
|
||||||
|
invoiceItems,
|
||||||
|
brandBooks,
|
||||||
|
projectMembers,
|
||||||
|
companyProfiles,
|
||||||
|
referencedProfiles,
|
||||||
|
fileRefs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectTaskSnapshot(taskIds) {
|
||||||
|
const tasks = taskIds.length ? await selectByValues('tasks', 'id', taskIds) : [];
|
||||||
|
const projectIds = uniqueValues(tasks.map(task => task.project_id));
|
||||||
|
const projects = projectIds.length ? await selectByValues('projects', 'id', projectIds) : [];
|
||||||
|
const companyIds = uniqueValues(projects.map(project => project.company_id));
|
||||||
|
const companies = companyIds.length ? await selectByValues('companies', 'id', companyIds) : [];
|
||||||
|
|
||||||
|
const submissions = taskIds.length ? await selectByValues('submissions', 'task_id', taskIds) : [];
|
||||||
|
const submissionIds = submissions.map(submission => submission.id);
|
||||||
|
const [submissionFiles, deliveries] = await Promise.all([
|
||||||
|
submissionIds.length ? selectByValues('submission_files', 'submission_id', submissionIds) : Promise.resolve([]),
|
||||||
|
submissionIds.length ? selectByValues('deliveries', 'submission_id', submissionIds) : Promise.resolve([]),
|
||||||
|
]);
|
||||||
|
const deliveryIds = deliveries.map(delivery => delivery.id);
|
||||||
|
const deliveryFiles = deliveryIds.length ? await selectByValues('delivery_files', 'delivery_id', deliveryIds) : [];
|
||||||
|
const invoiceItems = submissionIds.length
|
||||||
|
? (await selectByValues('invoice_items', 'submission_id', submissionIds)).filter(item => taskIds.includes(item.task_id))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const referencedProfileIds = uniqueValues([
|
||||||
|
...tasks.map(task => task.assigned_to),
|
||||||
|
...submissions.map(submission => submission.submitted_by),
|
||||||
|
]);
|
||||||
|
const referencedProfiles = referencedProfileIds.length
|
||||||
|
? await selectByValues('profiles', 'id', referencedProfileIds, 'id, name, email, role, company_id')
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const fileRefs = dedupeFileRefs([
|
||||||
|
...buildSubmissionFileRefs(submissionFiles),
|
||||||
|
...buildDeliveryFileRefs(deliveryFiles),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tasks,
|
||||||
|
projects,
|
||||||
|
companies,
|
||||||
|
submissions,
|
||||||
|
submissionFiles,
|
||||||
|
deliveries,
|
||||||
|
deliveryFiles,
|
||||||
|
invoiceItems,
|
||||||
|
referencedProfiles,
|
||||||
|
fileRefs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectBrandBookSnapshot(bookId) {
|
||||||
|
const { data: brandBook, error } = await supabase.from('brand_books').select('*').eq('id', bookId).single();
|
||||||
|
if (error) throw error;
|
||||||
|
if (!brandBook) throw new Error('Brand book not found.');
|
||||||
|
|
||||||
|
const company = brandBook.client_id
|
||||||
|
? (await supabase.from('companies').select('id, name').eq('id', brandBook.client_id).single()).data
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const fileRefs = dedupeFileRefs([
|
||||||
|
...buildBrandBookFileRefs([brandBook]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
brandBook,
|
||||||
|
company,
|
||||||
|
fileRefs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectBrandBooksSnapshot(bookIds) {
|
||||||
|
const brandBooks = bookIds.length ? await selectByValues('brand_books', 'id', bookIds) : [];
|
||||||
|
if (!brandBooks.length) throw new Error('No brand books found.');
|
||||||
|
|
||||||
|
const companyIds = uniqueValues(brandBooks.map(book => book.client_id));
|
||||||
|
const companies = companyIds.length ? await selectByValues('companies', 'id', companyIds, 'id, name') : [];
|
||||||
|
|
||||||
|
const fileRefs = dedupeFileRefs([
|
||||||
|
...buildBrandBookFileRefs(brandBooks),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
brandBooks,
|
||||||
|
companies,
|
||||||
|
fileRefs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildArchiveManifest(snapshot) {
|
||||||
|
return {
|
||||||
|
archive_version: ARCHIVE_VERSION,
|
||||||
|
archived_at: new Date().toISOString(),
|
||||||
|
scope: 'company',
|
||||||
|
company_id: snapshot.company.id,
|
||||||
|
company_name: snapshot.company.name,
|
||||||
|
data: {
|
||||||
|
company: snapshot.company,
|
||||||
|
company_prices: snapshot.companyPrices,
|
||||||
|
projects: snapshot.projects,
|
||||||
|
tasks: snapshot.tasks,
|
||||||
|
submissions: snapshot.submissions,
|
||||||
|
submission_files: snapshot.submissionFiles,
|
||||||
|
deliveries: snapshot.deliveries,
|
||||||
|
delivery_files: snapshot.deliveryFiles,
|
||||||
|
invoices: snapshot.invoices,
|
||||||
|
invoice_items: snapshot.invoiceItems,
|
||||||
|
brand_books: snapshot.brandBooks,
|
||||||
|
project_members: snapshot.projectMembers,
|
||||||
|
company_profiles: snapshot.companyProfiles,
|
||||||
|
referenced_profiles: snapshot.referencedProfiles,
|
||||||
|
},
|
||||||
|
storage_files: snapshot.fileRefs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCompaniesArchiveManifest(snapshot) {
|
||||||
|
return {
|
||||||
|
archive_version: ARCHIVE_VERSION,
|
||||||
|
archived_at: new Date().toISOString(),
|
||||||
|
scope: 'companies',
|
||||||
|
company_ids: snapshot.companies.map(company => company.id),
|
||||||
|
data: {
|
||||||
|
companies: snapshot.companies,
|
||||||
|
company_prices: snapshot.companyPrices,
|
||||||
|
projects: snapshot.projects,
|
||||||
|
tasks: snapshot.tasks,
|
||||||
|
submissions: snapshot.submissions,
|
||||||
|
submission_files: snapshot.submissionFiles,
|
||||||
|
deliveries: snapshot.deliveries,
|
||||||
|
delivery_files: snapshot.deliveryFiles,
|
||||||
|
invoices: snapshot.invoices,
|
||||||
|
invoice_items: snapshot.invoiceItems,
|
||||||
|
brand_books: snapshot.brandBooks,
|
||||||
|
project_members: snapshot.projectMembers,
|
||||||
|
company_profiles: snapshot.companyProfiles,
|
||||||
|
referenced_profiles: snapshot.referencedProfiles,
|
||||||
|
},
|
||||||
|
storage_files: snapshot.fileRefs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function archiveCompanyToLocalZip(companyId, options = {}) {
|
||||||
|
const { onProgress } = options;
|
||||||
|
reportProgress(onProgress, 'Collecting company data');
|
||||||
|
const snapshot = await collectCompanySnapshot(companyId);
|
||||||
|
const manifest = buildArchiveManifest(snapshot);
|
||||||
|
const zip = new JSZip();
|
||||||
|
|
||||||
|
zip.file('manifest.json', JSON.stringify(manifest, null, 2));
|
||||||
|
await fillZipWithFiles(zip, snapshot.fileRefs, onProgress);
|
||||||
|
|
||||||
|
reportProgress(onProgress, 'Packaging archive');
|
||||||
|
const blob = await zip.generateAsync({ type: 'blob' });
|
||||||
|
const filename = `${sanitizeFilename(snapshot.company.name)}_${snapshot.company.id}_archive.zip`;
|
||||||
|
reportProgress(onProgress, 'Starting download');
|
||||||
|
triggerDownload(blob, filename);
|
||||||
|
|
||||||
|
return {
|
||||||
|
filename,
|
||||||
|
companyName: snapshot.company.name,
|
||||||
|
fileCount: snapshot.fileRefs.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function archiveCompaniesToLocalZip(companyIds, options = {}) {
|
||||||
|
const { onProgress } = options;
|
||||||
|
reportProgress(onProgress, 'Collecting company data');
|
||||||
|
const companies = companyIds.length ? await selectByValues('companies', 'id', companyIds) : [];
|
||||||
|
if (!companies.length) throw new Error('No companies found.');
|
||||||
|
|
||||||
|
const snapshots = await Promise.all(companies.map(company => collectCompanySnapshot(company.id)));
|
||||||
|
const merged = {
|
||||||
|
companies: snapshots.map(snapshot => snapshot.company),
|
||||||
|
companyPrices: snapshots.flatMap(snapshot => snapshot.companyPrices),
|
||||||
|
projects: snapshots.flatMap(snapshot => snapshot.projects),
|
||||||
|
tasks: snapshots.flatMap(snapshot => snapshot.tasks),
|
||||||
|
submissions: snapshots.flatMap(snapshot => snapshot.submissions),
|
||||||
|
submissionFiles: snapshots.flatMap(snapshot => snapshot.submissionFiles),
|
||||||
|
deliveries: snapshots.flatMap(snapshot => snapshot.deliveries),
|
||||||
|
deliveryFiles: snapshots.flatMap(snapshot => snapshot.deliveryFiles),
|
||||||
|
invoices: snapshots.flatMap(snapshot => snapshot.invoices),
|
||||||
|
invoiceItems: snapshots.flatMap(snapshot => snapshot.invoiceItems),
|
||||||
|
brandBooks: snapshots.flatMap(snapshot => snapshot.brandBooks),
|
||||||
|
projectMembers: snapshots.flatMap(snapshot => snapshot.projectMembers),
|
||||||
|
companyProfiles: snapshots.flatMap(snapshot => snapshot.companyProfiles),
|
||||||
|
referencedProfiles: snapshots.flatMap(snapshot => snapshot.referencedProfiles),
|
||||||
|
fileRefs: dedupeFileRefs(snapshots.flatMap(snapshot => snapshot.fileRefs)),
|
||||||
|
};
|
||||||
|
|
||||||
|
const zip = new JSZip();
|
||||||
|
const manifest = buildCompaniesArchiveManifest(merged);
|
||||||
|
zip.file('manifest.json', JSON.stringify(manifest, null, 2));
|
||||||
|
|
||||||
|
await fillZipWithFiles(zip, merged.fileRefs, onProgress);
|
||||||
|
reportProgress(onProgress, 'Packaging archive');
|
||||||
|
const blob = await zip.generateAsync({ type: 'blob' });
|
||||||
|
const filename = `companies_${merged.companies.length}_${new Date().toISOString().slice(0, 10)}.zip`;
|
||||||
|
reportProgress(onProgress, 'Starting download');
|
||||||
|
triggerDownload(blob, filename);
|
||||||
|
|
||||||
|
return {
|
||||||
|
filename,
|
||||||
|
companyCount: merged.companies.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function archiveCompletedJobsToLocalZip(taskIds, options = {}) {
|
||||||
|
const { onProgress } = options;
|
||||||
|
reportProgress(onProgress, 'Collecting completed jobs');
|
||||||
|
const snapshot = await collectTaskSnapshot(taskIds);
|
||||||
|
if (!snapshot.tasks.length) throw new Error('No completed jobs found to archive.');
|
||||||
|
|
||||||
|
const zip = new JSZip();
|
||||||
|
const manifest = {
|
||||||
|
archive_version: ARCHIVE_VERSION,
|
||||||
|
archived_at: new Date().toISOString(),
|
||||||
|
scope: 'completed_tasks',
|
||||||
|
task_count: snapshot.tasks.length,
|
||||||
|
company_refs: snapshot.companies,
|
||||||
|
project_refs: snapshot.projects,
|
||||||
|
data: {
|
||||||
|
tasks: snapshot.tasks,
|
||||||
|
submissions: snapshot.submissions,
|
||||||
|
submission_files: snapshot.submissionFiles,
|
||||||
|
deliveries: snapshot.deliveries,
|
||||||
|
delivery_files: snapshot.deliveryFiles,
|
||||||
|
invoice_items: snapshot.invoiceItems,
|
||||||
|
referenced_profiles: snapshot.referencedProfiles,
|
||||||
|
},
|
||||||
|
storage_files: snapshot.fileRefs,
|
||||||
|
};
|
||||||
|
|
||||||
|
zip.file('manifest.json', JSON.stringify(manifest, null, 2));
|
||||||
|
await fillZipWithFiles(zip, snapshot.fileRefs, onProgress);
|
||||||
|
reportProgress(onProgress, 'Packaging archive');
|
||||||
|
const blob = await zip.generateAsync({ type: 'blob' });
|
||||||
|
const filename = `completed_jobs_${snapshot.tasks.length}_${new Date().toISOString().slice(0, 10)}.zip`;
|
||||||
|
reportProgress(onProgress, 'Starting download');
|
||||||
|
triggerDownload(blob, filename);
|
||||||
|
|
||||||
|
return {
|
||||||
|
filename,
|
||||||
|
taskCount: snapshot.tasks.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function archiveBrandBookToLocalZip(bookId, options = {}) {
|
||||||
|
const { onProgress } = options;
|
||||||
|
reportProgress(onProgress, 'Collecting brand book');
|
||||||
|
const snapshot = await collectBrandBookSnapshot(bookId);
|
||||||
|
const zip = new JSZip();
|
||||||
|
const manifest = {
|
||||||
|
archive_version: ARCHIVE_VERSION,
|
||||||
|
archived_at: new Date().toISOString(),
|
||||||
|
scope: 'brand_book',
|
||||||
|
brand_book_id: snapshot.brandBook.id,
|
||||||
|
client_ref: snapshot.company,
|
||||||
|
data: {
|
||||||
|
brand_book: snapshot.brandBook,
|
||||||
|
},
|
||||||
|
storage_files: snapshot.fileRefs,
|
||||||
|
};
|
||||||
|
|
||||||
|
zip.file('manifest.json', JSON.stringify(manifest, null, 2));
|
||||||
|
await fillZipWithFiles(zip, snapshot.fileRefs, onProgress);
|
||||||
|
reportProgress(onProgress, 'Packaging archive');
|
||||||
|
const blob = await zip.generateAsync({ type: 'blob' });
|
||||||
|
const filename = `${sanitizeFilename(snapshot.brandBook.client_name || 'brand_book')}_${snapshot.brandBook.id}_brand_book.zip`;
|
||||||
|
reportProgress(onProgress, 'Starting download');
|
||||||
|
triggerDownload(blob, filename);
|
||||||
|
|
||||||
|
return {
|
||||||
|
filename,
|
||||||
|
bookId: snapshot.brandBook.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function archiveBrandBooksToLocalZip(bookIds, options = {}) {
|
||||||
|
const { onProgress } = options;
|
||||||
|
reportProgress(onProgress, 'Collecting brand books');
|
||||||
|
const snapshot = await collectBrandBooksSnapshot(bookIds);
|
||||||
|
const zip = new JSZip();
|
||||||
|
const manifest = {
|
||||||
|
archive_version: ARCHIVE_VERSION,
|
||||||
|
archived_at: new Date().toISOString(),
|
||||||
|
scope: 'brand_books',
|
||||||
|
brand_book_ids: snapshot.brandBooks.map(book => book.id),
|
||||||
|
company_refs: snapshot.companies,
|
||||||
|
data: {
|
||||||
|
brand_books: snapshot.brandBooks,
|
||||||
|
},
|
||||||
|
storage_files: snapshot.fileRefs,
|
||||||
|
};
|
||||||
|
|
||||||
|
zip.file('manifest.json', JSON.stringify(manifest, null, 2));
|
||||||
|
await fillZipWithFiles(zip, snapshot.fileRefs, onProgress);
|
||||||
|
reportProgress(onProgress, 'Packaging archive');
|
||||||
|
const blob = await zip.generateAsync({ type: 'blob' });
|
||||||
|
const filename = `brand_books_${snapshot.brandBooks.length}_${new Date().toISOString().slice(0, 10)}.zip`;
|
||||||
|
reportProgress(onProgress, 'Starting download');
|
||||||
|
triggerDownload(blob, filename);
|
||||||
|
|
||||||
|
return {
|
||||||
|
filename,
|
||||||
|
bookCount: snapshot.brandBooks.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertNoRestoreConflicts(manifest) {
|
||||||
|
const companyRows = manifest.data.companies || (manifest.data.company ? [manifest.data.company] : []);
|
||||||
|
const checks = [
|
||||||
|
['companies', companyRows.map(row => row.id)],
|
||||||
|
['company_prices', (manifest.data.company_prices || []).map(row => row.id)],
|
||||||
|
['projects', (manifest.data.projects || []).map(row => row.id)],
|
||||||
|
['tasks', (manifest.data.tasks || []).map(row => row.id)],
|
||||||
|
['submissions', (manifest.data.submissions || []).map(row => row.id)],
|
||||||
|
['submission_files', (manifest.data.submission_files || []).map(row => row.id)],
|
||||||
|
['deliveries', (manifest.data.deliveries || []).map(row => row.id)],
|
||||||
|
['delivery_files', (manifest.data.delivery_files || []).map(row => row.id)],
|
||||||
|
['invoices', (manifest.data.invoices || []).map(row => row.id)],
|
||||||
|
['invoice_items', (manifest.data.invoice_items || []).map(row => row.id)],
|
||||||
|
['brand_books', (manifest.data.brand_books || []).map(row => row.id)],
|
||||||
|
['project_members', (manifest.data.project_members || []).map(row => row.id)],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [table, ids] of checks) {
|
||||||
|
const values = uniqueValues(ids);
|
||||||
|
if (!values.length) continue;
|
||||||
|
const existing = await selectByValues(table, 'id', values, 'id');
|
||||||
|
if (existing.length) {
|
||||||
|
throw new Error(`Restore blocked: ${table} already contains archived IDs.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readAnyArchiveManifest(file, onProgress) {
|
||||||
|
reportProgress(onProgress, 'Reading archive');
|
||||||
|
const zip = await JSZip.loadAsync(file);
|
||||||
|
const manifestFile = zip.file('manifest.json');
|
||||||
|
if (!manifestFile) throw new Error('Invalid archive: manifest.json is missing.');
|
||||||
|
|
||||||
|
reportProgress(onProgress, 'Reading manifest');
|
||||||
|
const manifest = JSON.parse(await manifestFile.async('string'));
|
||||||
|
if (manifest.archive_version !== ARCHIVE_VERSION) throw new Error('Unsupported archive version.');
|
||||||
|
|
||||||
|
return { zip, manifest };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadArchiveFiles(zip, fileRefs, onProgress) {
|
||||||
|
const refs = fileRefs || [];
|
||||||
|
if (!refs.length) {
|
||||||
|
reportProgress(onProgress, 'No files to restore');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < refs.length; i += 1) {
|
||||||
|
const ref = refs[i];
|
||||||
|
const zipFile = zip.file(ref.zipPath);
|
||||||
|
if (!zipFile) throw new Error(`Archive is missing ${ref.zipPath}.`);
|
||||||
|
|
||||||
|
reportProgress(onProgress, `Uploading files ${i + 1}/${refs.length}`);
|
||||||
|
const blob = await zipFile.async('blob');
|
||||||
|
const { error } = await supabase.storage.from(ref.bucket).upload(ref.path, blob, {
|
||||||
|
upsert: true,
|
||||||
|
contentType: blob.type || undefined,
|
||||||
|
});
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMissingProfileSummary(existingProfileIds, manifest) {
|
||||||
|
const hasProfile = id => !id || existingProfileIds.has(id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
companyProfiles: (manifest.data.company_profiles || []).filter(profile => !existingProfileIds.has(profile.id)).length,
|
||||||
|
taskAssignments: (manifest.data.tasks || []).filter(task => task.assigned_to && !hasProfile(task.assigned_to)).length,
|
||||||
|
submissions: (manifest.data.submissions || []).filter(submission => submission.submitted_by && !hasProfile(submission.submitted_by)).length,
|
||||||
|
invoices: (manifest.data.invoices || []).filter(invoice => invoice.created_by && !hasProfile(invoice.created_by)).length,
|
||||||
|
projectMembers: (manifest.data.project_members || []).filter(member => member.profile_id && !hasProfile(member.profile_id)).length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restoreCompanyArchive(file, options = {}) {
|
||||||
|
const { onProgress } = options;
|
||||||
|
const { zip, manifest } = await readAnyArchiveManifest(file, onProgress);
|
||||||
|
if (!['company', 'companies'].includes(manifest.scope)) {
|
||||||
|
throw new Error('This archive is not a company archive.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedManifest = manifest.scope === 'company'
|
||||||
|
? manifest
|
||||||
|
: {
|
||||||
|
...manifest,
|
||||||
|
data: {
|
||||||
|
company: null,
|
||||||
|
company_prices: manifest.data.company_prices || [],
|
||||||
|
projects: manifest.data.projects || [],
|
||||||
|
tasks: manifest.data.tasks || [],
|
||||||
|
submissions: manifest.data.submissions || [],
|
||||||
|
submission_files: manifest.data.submission_files || [],
|
||||||
|
deliveries: manifest.data.deliveries || [],
|
||||||
|
delivery_files: manifest.data.delivery_files || [],
|
||||||
|
invoices: manifest.data.invoices || [],
|
||||||
|
invoice_items: manifest.data.invoice_items || [],
|
||||||
|
brand_books: manifest.data.brand_books || [],
|
||||||
|
project_members: manifest.data.project_members || [],
|
||||||
|
company_profiles: manifest.data.company_profiles || [],
|
||||||
|
referenced_profiles: manifest.data.referenced_profiles || [],
|
||||||
|
companies: manifest.data.companies || [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const companyRows = manifest.scope === 'company'
|
||||||
|
? [manifest.data.company]
|
||||||
|
: (manifest.data.companies || []);
|
||||||
|
|
||||||
|
const companyScopedManifest = {
|
||||||
|
...normalizedManifest,
|
||||||
|
data: {
|
||||||
|
...normalizedManifest.data,
|
||||||
|
companies: companyRows,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
reportProgress(onProgress, 'Validating restore');
|
||||||
|
await assertNoRestoreConflicts({
|
||||||
|
...companyScopedManifest,
|
||||||
|
data: {
|
||||||
|
...companyScopedManifest.data,
|
||||||
|
company: companyRows[0],
|
||||||
|
companies: companyRows,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const referencedProfileIds = uniqueValues([
|
||||||
|
...(companyScopedManifest.data.company_profiles || []).map(profile => profile.id),
|
||||||
|
...(companyScopedManifest.data.project_members || []).map(member => member.profile_id),
|
||||||
|
...(companyScopedManifest.data.tasks || []).map(task => task.assigned_to),
|
||||||
|
...(companyScopedManifest.data.submissions || []).map(submission => submission.submitted_by),
|
||||||
|
...(companyScopedManifest.data.invoices || []).map(invoice => invoice.created_by),
|
||||||
|
]);
|
||||||
|
const existingProfiles = referencedProfileIds.length
|
||||||
|
? await selectByValues('profiles', 'id', referencedProfileIds, 'id')
|
||||||
|
: [];
|
||||||
|
const existingProfileIds = new Set(existingProfiles.map(profile => profile.id));
|
||||||
|
|
||||||
|
await uploadArchiveFiles(zip, manifest.storage_files, onProgress);
|
||||||
|
|
||||||
|
reportProgress(onProgress, 'Restoring database records');
|
||||||
|
const restoredTasks = (companyScopedManifest.data.tasks || []).map(task => ({
|
||||||
|
...task,
|
||||||
|
assigned_to: existingProfileIds.has(task.assigned_to) ? task.assigned_to : null,
|
||||||
|
}));
|
||||||
|
const restoredSubmissions = (companyScopedManifest.data.submissions || []).map(submission => ({
|
||||||
|
...submission,
|
||||||
|
submitted_by: existingProfileIds.has(submission.submitted_by) ? submission.submitted_by : null,
|
||||||
|
}));
|
||||||
|
const restoredInvoices = (companyScopedManifest.data.invoices || []).map(invoice => ({
|
||||||
|
...invoice,
|
||||||
|
created_by: existingProfileIds.has(invoice.created_by) ? invoice.created_by : null,
|
||||||
|
}));
|
||||||
|
const restoredProjectMembers = (companyScopedManifest.data.project_members || []).filter(member => existingProfileIds.has(member.profile_id));
|
||||||
|
const restoredCompanyProfileRows = (companyScopedManifest.data.company_profiles || [])
|
||||||
|
.filter(profile => existingProfileIds.has(profile.id));
|
||||||
|
|
||||||
|
await insertRows('companies', companyRows);
|
||||||
|
await insertRows('company_prices', companyScopedManifest.data.company_prices || []);
|
||||||
|
await insertRows('projects', companyScopedManifest.data.projects || []);
|
||||||
|
await insertRows('tasks', restoredTasks);
|
||||||
|
await insertRows('submissions', restoredSubmissions);
|
||||||
|
await insertRows('submission_files', companyScopedManifest.data.submission_files || []);
|
||||||
|
await insertRows('deliveries', companyScopedManifest.data.deliveries || []);
|
||||||
|
await insertRows('delivery_files', companyScopedManifest.data.delivery_files || []);
|
||||||
|
await insertRows('invoices', restoredInvoices);
|
||||||
|
await insertRows('invoice_items', companyScopedManifest.data.invoice_items || []);
|
||||||
|
await insertRows('brand_books', companyScopedManifest.data.brand_books || []);
|
||||||
|
await insertRows('project_members', restoredProjectMembers);
|
||||||
|
|
||||||
|
if (restoredCompanyProfileRows.length) {
|
||||||
|
reportProgress(onProgress, 'Re-linking existing users');
|
||||||
|
const profilesByCompany = restoredCompanyProfileRows.reduce((acc, profile) => {
|
||||||
|
const key = profile.company_id;
|
||||||
|
if (!key) return acc;
|
||||||
|
if (!acc[key]) acc[key] = [];
|
||||||
|
acc[key].push(profile.id);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
for (const [companyId, profileIds] of Object.entries(profilesByCompany)) {
|
||||||
|
for (const batch of chunk(profileIds, ROW_BATCH_SIZE)) {
|
||||||
|
const { error } = await supabase.from('profiles')
|
||||||
|
.update({ company_id: companyId })
|
||||||
|
.in('id', batch);
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
companyName: companyRows[0]?.name || manifest.company_name,
|
||||||
|
companyCount: companyRows.length,
|
||||||
|
missingProfiles: getMissingProfileSummary(existingProfileIds, {
|
||||||
|
data: {
|
||||||
|
company_profiles: companyScopedManifest.data.company_profiles || [],
|
||||||
|
project_members: companyScopedManifest.data.project_members || [],
|
||||||
|
tasks: companyScopedManifest.data.tasks || [],
|
||||||
|
submissions: companyScopedManifest.data.submissions || [],
|
||||||
|
invoices: companyScopedManifest.data.invoices || [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restoreCompletedJobsArchive(file, options = {}) {
|
||||||
|
const { onProgress } = options;
|
||||||
|
const { zip, manifest } = await readAnyArchiveManifest(file, onProgress);
|
||||||
|
if (manifest.scope !== 'completed_tasks') throw new Error('This archive is not a completed jobs archive.');
|
||||||
|
|
||||||
|
reportProgress(onProgress, 'Validating restore');
|
||||||
|
const taskIds = uniqueValues((manifest.data.tasks || []).map(task => task.id));
|
||||||
|
const submissionIds = uniqueValues((manifest.data.submissions || []).map(submission => submission.id));
|
||||||
|
const deliveryIds = uniqueValues((manifest.data.deliveries || []).map(delivery => delivery.id));
|
||||||
|
const companyIds = uniqueValues((manifest.company_refs || []).map(company => company.id));
|
||||||
|
const projectIds = uniqueValues((manifest.project_refs || []).map(project => project.id));
|
||||||
|
const invoiceItemIds = uniqueValues((manifest.data.invoice_items || []).map(item => item.id));
|
||||||
|
|
||||||
|
const [existingTasks, existingSubmissions, existingDeliveries, existingCompanies, existingProjects, existingInvoiceItems] = await Promise.all([
|
||||||
|
taskIds.length ? selectByValues('tasks', 'id', taskIds, 'id') : Promise.resolve([]),
|
||||||
|
submissionIds.length ? selectByValues('submissions', 'id', submissionIds, 'id') : Promise.resolve([]),
|
||||||
|
deliveryIds.length ? selectByValues('deliveries', 'id', deliveryIds, 'id') : Promise.resolve([]),
|
||||||
|
companyIds.length ? selectByValues('companies', 'id', companyIds, 'id') : Promise.resolve([]),
|
||||||
|
projectIds.length ? selectByValues('projects', 'id', projectIds, 'id') : Promise.resolve([]),
|
||||||
|
invoiceItemIds.length ? selectByValues('invoice_items', 'id', invoiceItemIds, 'id') : Promise.resolve([]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (existingTasks.length || existingSubmissions.length || existingDeliveries.length) {
|
||||||
|
throw new Error('Restore blocked: one or more archived job IDs already exist.');
|
||||||
|
}
|
||||||
|
if (existingCompanies.length !== companyIds.length) {
|
||||||
|
throw new Error('Restore blocked: one or more original companies no longer exist.');
|
||||||
|
}
|
||||||
|
if (existingProjects.length !== projectIds.length) {
|
||||||
|
throw new Error('Restore blocked: one or more original projects no longer exist.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const referencedProfileIds = uniqueValues([
|
||||||
|
...(manifest.data.tasks || []).map(task => task.assigned_to),
|
||||||
|
...(manifest.data.submissions || []).map(submission => submission.submitted_by),
|
||||||
|
]);
|
||||||
|
const existingProfiles = referencedProfileIds.length
|
||||||
|
? await selectByValues('profiles', 'id', referencedProfileIds, 'id')
|
||||||
|
: [];
|
||||||
|
const existingProfileIds = new Set(existingProfiles.map(profile => profile.id));
|
||||||
|
|
||||||
|
await uploadArchiveFiles(zip, manifest.storage_files, onProgress);
|
||||||
|
|
||||||
|
reportProgress(onProgress, 'Restoring database records');
|
||||||
|
const restoredTasks = (manifest.data.tasks || []).map(task => ({
|
||||||
|
...task,
|
||||||
|
assigned_to: existingProfileIds.has(task.assigned_to) ? task.assigned_to : null,
|
||||||
|
}));
|
||||||
|
const restoredSubmissions = (manifest.data.submissions || []).map(submission => ({
|
||||||
|
...submission,
|
||||||
|
submitted_by: existingProfileIds.has(submission.submitted_by) ? submission.submitted_by : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await insertRows('tasks', restoredTasks);
|
||||||
|
await insertRows('submissions', restoredSubmissions);
|
||||||
|
await insertRows('submission_files', manifest.data.submission_files || []);
|
||||||
|
await insertRows('deliveries', manifest.data.deliveries || []);
|
||||||
|
await insertRows('delivery_files', manifest.data.delivery_files || []);
|
||||||
|
|
||||||
|
const existingInvoiceItemIds = new Set(existingInvoiceItems.map(item => item.id));
|
||||||
|
const invoiceItemsToInsert = [];
|
||||||
|
const invoiceItemsToUpdate = [];
|
||||||
|
for (const item of manifest.data.invoice_items || []) {
|
||||||
|
if (existingInvoiceItemIds.has(item.id)) invoiceItemsToUpdate.push(item);
|
||||||
|
else invoiceItemsToInsert.push(item);
|
||||||
|
}
|
||||||
|
await insertRows('invoice_items', invoiceItemsToInsert);
|
||||||
|
for (const item of invoiceItemsToUpdate) {
|
||||||
|
const { error } = await supabase.from('invoice_items')
|
||||||
|
.update({ task_id: item.task_id, submission_id: item.submission_id })
|
||||||
|
.eq('id', item.id);
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
taskCount: restoredTasks.length,
|
||||||
|
clearedAssignments: restoredTasks.filter(task => !task.assigned_to).length,
|
||||||
|
clearedSubmissionUsers: restoredSubmissions.filter(submission => !submission.submitted_by).length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restoreBrandBookArchive(file, options = {}) {
|
||||||
|
const { onProgress } = options;
|
||||||
|
const { zip, manifest } = await readAnyArchiveManifest(file, onProgress);
|
||||||
|
if (!['brand_book', 'brand_books'].includes(manifest.scope)) {
|
||||||
|
throw new Error('This archive is not a brand book archive.');
|
||||||
|
}
|
||||||
|
|
||||||
|
reportProgress(onProgress, 'Validating restore');
|
||||||
|
const books = manifest.scope === 'brand_book'
|
||||||
|
? [manifest.data.brand_book]
|
||||||
|
: (manifest.data.brand_books || []);
|
||||||
|
|
||||||
|
const existingBookIds = uniqueValues(books.map(book => book.id));
|
||||||
|
const existingBooks = existingBookIds.length ? await selectByValues('brand_books', 'id', existingBookIds, 'id') : [];
|
||||||
|
if (existingBooks.length) throw new Error('Restore blocked: one or more brand book IDs already exist.');
|
||||||
|
|
||||||
|
const companyIds = uniqueValues(books.map(book => book.client_id));
|
||||||
|
const existingCompanies = companyIds.length ? await selectByValues('companies', 'id', companyIds, 'id') : [];
|
||||||
|
const existingCompanyIds = new Set(existingCompanies.map(company => company.id));
|
||||||
|
|
||||||
|
const restoredBooks = books.map(book => (
|
||||||
|
book.client_id && !existingCompanyIds.has(book.client_id)
|
||||||
|
? { ...book, client_id: null }
|
||||||
|
: { ...book }
|
||||||
|
));
|
||||||
|
|
||||||
|
await uploadArchiveFiles(zip, manifest.storage_files, onProgress);
|
||||||
|
reportProgress(onProgress, 'Restoring database records');
|
||||||
|
await insertRows('brand_books', restoredBooks);
|
||||||
|
|
||||||
|
return {
|
||||||
|
bookCount: restoredBooks.length,
|
||||||
|
unlinkedCount: restoredBooks.filter(book => !book.client_id).length,
|
||||||
|
clientName: restoredBooks[0]?.client_name,
|
||||||
|
};
|
||||||
|
}
|
||||||
+554
-159
@@ -14,33 +14,252 @@ function formatDate(dateStr) {
|
|||||||
return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
|
return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Accepts File, data URL string, or https URL string — returns data URL
|
function formatCoverAddress(address) {
|
||||||
|
const parts = String(address || '')
|
||||||
|
.split(',')
|
||||||
|
.map(part => part.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
const countryless = parts[parts.length - 1].toLowerCase() === 'united states' || parts[parts.length - 1].toLowerCase() === 'usa'
|
||||||
|
? parts.slice(0, -1)
|
||||||
|
: parts;
|
||||||
|
return [
|
||||||
|
countryless[0],
|
||||||
|
countryless.slice(1).join(', '),
|
||||||
|
].filter(Boolean);
|
||||||
|
}
|
||||||
|
return address ? [address] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse JPEG EXIF orientation tag from raw bytes (returns 1–8; 1 = normal).
|
||||||
|
function parseExifOrientation(bytes) {
|
||||||
|
if (bytes.length < 4 || bytes[0] !== 0xFF || bytes[1] !== 0xD8) return 1;
|
||||||
|
let pos = 2;
|
||||||
|
while (pos < bytes.length - 4) {
|
||||||
|
if (bytes[pos] !== 0xFF) break;
|
||||||
|
const marker = bytes[pos + 1];
|
||||||
|
if (marker === 0xDA) break; // SOS — no more metadata
|
||||||
|
const segLen = (bytes[pos + 2] << 8) | bytes[pos + 3];
|
||||||
|
if (marker === 0xE1 && segLen >= 14 &&
|
||||||
|
bytes[pos+4]===0x45 && bytes[pos+5]===0x78 && bytes[pos+6]===0x69 &&
|
||||||
|
bytes[pos+7]===0x66 && bytes[pos+8]===0x00 && bytes[pos+9]===0x00) {
|
||||||
|
const tiff = pos + 10;
|
||||||
|
const le = bytes[tiff] === 0x49;
|
||||||
|
const r16 = (o) => le ? (bytes[o]|(bytes[o+1]<<8)) : ((bytes[o]<<8)|bytes[o+1]);
|
||||||
|
const r32 = (o) => le
|
||||||
|
? ((bytes[o]|(bytes[o+1]<<8)|(bytes[o+2]<<16)|(bytes[o+3]<<24))>>>0)
|
||||||
|
: (((bytes[o]<<24)|(bytes[o+1]<<16)|(bytes[o+2]<<8)|bytes[o+3])>>>0);
|
||||||
|
const ifd = tiff + r32(tiff + 4);
|
||||||
|
if (ifd + 2 > bytes.length) break;
|
||||||
|
const n = r16(ifd);
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const e = ifd + 2 + i * 12;
|
||||||
|
if (e + 12 > bytes.length) break;
|
||||||
|
if (r16(e) === 0x0112) return r16(e + 8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pos += 2 + segLen;
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply EXIF orientation correction to a Blob → returns corrected data URL.
|
||||||
|
//
|
||||||
|
// Strategy: try createImageBitmap(imageOrientation:'none') first to get raw pixels,
|
||||||
|
// then apply explicit canvas transforms. BUT some browsers (older Safari) ignore the
|
||||||
|
// option and return already-corrected pixels — detect this by comparing bitmap
|
||||||
|
// dimensions to what the EXIF orientation implies for raw pixels, and skip the
|
||||||
|
// transform if the browser already corrected. Falls back to img→canvas if
|
||||||
|
// createImageBitmap throws.
|
||||||
|
async function correctOrientation(blob) {
|
||||||
|
if (!blob) return null;
|
||||||
|
const isPng = blob.type === 'image/png';
|
||||||
|
const toDataUrl = (b) => new Promise((res) => {
|
||||||
|
const r = new FileReader();
|
||||||
|
r.onload = (e) => res(e.target.result);
|
||||||
|
r.onerror = () => res(null);
|
||||||
|
r.readAsDataURL(b);
|
||||||
|
});
|
||||||
|
if (isPng) return toDataUrl(blob);
|
||||||
|
|
||||||
|
let orientation = 1;
|
||||||
|
let ab = null;
|
||||||
|
try { ab = await blob.arrayBuffer(); orientation = parseExifOrientation(new Uint8Array(ab)); }
|
||||||
|
catch { /* treat as normal */ }
|
||||||
|
|
||||||
|
if (orientation <= 1) return toDataUrl(blob);
|
||||||
|
|
||||||
|
// Helper: build canvas with explicit transform if needed.
|
||||||
|
// swapAxes orientations (5-8): raw JPEG stores landscape pixels.
|
||||||
|
// If browser ignored imageOrientation:'none', it returned corrected (portrait) pixels —
|
||||||
|
// detect this by checking bmpW > bmpH (raw=landscape) vs bmpW < bmpH (corrected=portrait).
|
||||||
|
const applyToCanvas = (source, bmpW, bmpH) => {
|
||||||
|
const swapAxes = orientation >= 5;
|
||||||
|
// For swap-axes orientations: raw = landscape (w > h), corrected = portrait (w < h)
|
||||||
|
const isRaw = swapAxes ? (bmpW > bmpH) : true;
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
const ctx = c.getContext('2d');
|
||||||
|
if (isRaw && swapAxes) { c.width = bmpH; c.height = bmpW; }
|
||||||
|
else { c.width = bmpW; c.height = bmpH; }
|
||||||
|
if (isRaw) {
|
||||||
|
switch (orientation) {
|
||||||
|
case 2: ctx.transform(-1, 0, 0, 1, bmpW, 0); break;
|
||||||
|
case 3: ctx.transform(-1, 0, 0, -1, bmpW, bmpH); break;
|
||||||
|
case 4: ctx.transform( 1, 0, 0, -1, 0, bmpH); break;
|
||||||
|
case 5: ctx.transform( 0, 1, 1, 0, 0, 0); break;
|
||||||
|
case 6: ctx.transform( 0, 1, -1, 0, bmpH, 0); break;
|
||||||
|
case 7: ctx.transform( 0, -1, -1, 0, bmpH, bmpW); break;
|
||||||
|
case 8: ctx.transform( 0, -1, 1, 0, 0, bmpW); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.drawImage(source, 0, 0);
|
||||||
|
return c.toDataURL('image/jpeg', 0.92);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Primary: createImageBitmap with imageOrientation:'none'
|
||||||
|
try {
|
||||||
|
const src = ab ? new Blob([ab], { type: blob.type }) : blob;
|
||||||
|
const bmp = await createImageBitmap(src, { imageOrientation: 'none' });
|
||||||
|
const result = applyToCanvas(bmp, bmp.width, bmp.height);
|
||||||
|
bmp.close();
|
||||||
|
return result;
|
||||||
|
} catch { /* fall through */ }
|
||||||
|
|
||||||
|
// Fallback: img→canvas (browser may auto-correct EXIF — dimension check handles both cases)
|
||||||
|
const dataUrl = await toDataUrl(blob);
|
||||||
|
if (!dataUrl) return null;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
try { resolve(applyToCanvas(img, img.naturalWidth, img.naturalHeight)); }
|
||||||
|
catch { resolve(dataUrl); }
|
||||||
|
};
|
||||||
|
img.onerror = () => resolve(dataUrl);
|
||||||
|
img.src = dataUrl;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDataUrlMimeType(dataUrl) {
|
||||||
|
if (typeof dataUrl !== 'string') return '';
|
||||||
|
const match = dataUrl.match(/^data:([^;,]+)[;,]/i);
|
||||||
|
return match?.[1]?.toLowerCase() || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPdfSafeDataUrl(dataUrl) {
|
||||||
|
const mime = getDataUrlMimeType(dataUrl);
|
||||||
|
return mime === 'image/png' || mime === 'image/jpeg';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getJsPdfFormat(dataUrl) {
|
||||||
|
return getDataUrlMimeType(dataUrl) === 'image/png' ? 'PNG' : 'JPEG';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensurePdfSafeDataUrl(dataUrl, preferPng = false) {
|
||||||
|
if (!dataUrl) return null;
|
||||||
|
if (isPdfSafeDataUrl(dataUrl)) return dataUrl;
|
||||||
|
|
||||||
|
const img = await loadImage(dataUrl);
|
||||||
|
if (!img) return null;
|
||||||
|
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
c.width = img.naturalWidth || img.width || 1;
|
||||||
|
c.height = img.naturalHeight || img.height || 1;
|
||||||
|
const ctx = c.getContext('2d');
|
||||||
|
if (!ctx) return null;
|
||||||
|
|
||||||
|
if (!preferPng) {
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.fillRect(0, 0, c.width, c.height);
|
||||||
|
}
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
|
||||||
|
return preferPng
|
||||||
|
? c.toDataURL('image/png')
|
||||||
|
: c.toDataURL('image/jpeg', 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDataUrlImage(doc, dataUrl, x, y, w, h) {
|
||||||
|
if (!dataUrl) return;
|
||||||
|
doc.addImage(dataUrl, getJsPdfFormat(dataUrl), x, y, w, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accepts File/Blob, data URL string, or https/blob URL string.
|
||||||
|
// Returns EXIF-corrected, PDF-safe data URL, or null on failure.
|
||||||
|
// 15-second fetch timeout prevents infinite hangs on stalled network requests.
|
||||||
async function resolvePhoto(source) {
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,546 +0,0 @@
|
|||||||
import jsPDF from 'jspdf';
|
|
||||||
|
|
||||||
function loadImage(src) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const img = new Image();
|
|
||||||
img.crossOrigin = 'anonymous';
|
|
||||||
img.onload = () => resolve(img);
|
|
||||||
img.onerror = () => resolve(null);
|
|
||||||
img.src = src;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function hexToRgb(hex) {
|
|
||||||
const clean = hex.replace('#', '');
|
|
||||||
const bigint = parseInt(clean, 16);
|
|
||||||
return [(bigint >> 16) & 255, (bigint >> 8) & 255, bigint & 255];
|
|
||||||
}
|
|
||||||
|
|
||||||
function isLight(hex) {
|
|
||||||
const [r, g, b] = hexToRgb(hex);
|
|
||||||
return (r * 299 + g * 587 + b * 114) / 1000 > 160;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getImgFormat(dataUrl) {
|
|
||||||
if (!dataUrl) return 'PNG';
|
|
||||||
if (/image\/jpe?g/i.test(dataUrl)) return 'JPEG';
|
|
||||||
return 'PNG';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toDataUrl(url) {
|
|
||||||
const img = await loadImage(url);
|
|
||||||
if (!img) return null;
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
canvas.width = img.naturalWidth;
|
|
||||||
canvas.height = img.naturalHeight;
|
|
||||||
canvas.getContext('2d').drawImage(img, 0, 0);
|
|
||||||
return canvas.toDataURL('image/png');
|
|
||||||
}
|
|
||||||
|
|
||||||
function sectionHeader(doc, label, y, pageWidth) {
|
|
||||||
doc.setFillColor(245, 165, 35);
|
|
||||||
doc.rect(14, y, 3, 10, 'F');
|
|
||||||
doc.setFontSize(10);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.setTextColor(30, 30, 30);
|
|
||||||
doc.text(label.toUpperCase(), 21, y + 7);
|
|
||||||
return y + 18;
|
|
||||||
}
|
|
||||||
|
|
||||||
function addHeader(doc, pageWidth, logo, logoW, logoH, headerH) {
|
|
||||||
doc.setFillColor(20, 20, 20);
|
|
||||||
doc.rect(0, 0, pageWidth, headerH, 'F');
|
|
||||||
if (logo) {
|
|
||||||
doc.addImage(logo, 'PNG', 14, 6, logoW, logoH);
|
|
||||||
} else {
|
|
||||||
doc.setFontSize(10);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.setTextColor(255, 255, 255);
|
|
||||||
doc.text('FOURGE BRANDING', 14, headerH / 2 + 3);
|
|
||||||
}
|
|
||||||
doc.setFontSize(7);
|
|
||||||
doc.setFont('helvetica', 'normal');
|
|
||||||
doc.setTextColor(130, 130, 130);
|
|
||||||
doc.text('hello@fourgebranding.com · www.fourgebranding.com', pageWidth - 14, headerH / 2 + 2, { align: 'right' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function addPageNumber(doc, pageNum, total, pageHeight, pageWidth) {
|
|
||||||
doc.setFontSize(7);
|
|
||||||
doc.setFont('helvetica', 'normal');
|
|
||||||
doc.setTextColor(180, 180, 180);
|
|
||||||
doc.text(`${pageNum} / ${total}`, pageWidth - 14, pageHeight - 8, { align: 'right' });
|
|
||||||
doc.setDrawColor(230, 230, 230);
|
|
||||||
doc.setLineWidth(0.3);
|
|
||||||
doc.line(14, pageHeight - 13, pageWidth - 14, pageHeight - 13);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateStr) {
|
|
||||||
if (!dateStr) return '—';
|
|
||||||
const d = new Date(dateStr + 'T00:00:00');
|
|
||||||
return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateBrandBookPDF(data) {
|
|
||||||
const doc = new jsPDF();
|
|
||||||
const pageWidth = doc.internal.pageSize.width;
|
|
||||||
const pageHeight = doc.internal.pageSize.height;
|
|
||||||
|
|
||||||
// Preload Fourge logo for inner pages
|
|
||||||
const fourgeLogoImg = await loadImage('/fourge-logo.png');
|
|
||||||
const logoW = 36;
|
|
||||||
const logoH = fourgeLogoImg ? (logoW / (fourgeLogoImg.naturalWidth / fourgeLogoImg.naturalHeight)) : 8;
|
|
||||||
const headerH = logoH + 12;
|
|
||||||
|
|
||||||
// Pre-convert client logo URL to data URL
|
|
||||||
let clientLogoDataUrl = null;
|
|
||||||
if (data.clientLogoUrl) {
|
|
||||||
clientLogoDataUrl = await toDataUrl(data.clientLogoUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
const colors = (data.colors || []).filter(c => c.name || c.hex);
|
|
||||||
const hasFonts = data.primaryFont || data.secondaryFont || data.fontNotes;
|
|
||||||
const hasVoice = data.brandVoice || data.brandAdjectives;
|
|
||||||
const hasLogo = data.logoNotes;
|
|
||||||
const hasDoDont = data.dos || data.donts;
|
|
||||||
|
|
||||||
let totalPages = 1;
|
|
||||||
if (data.brandStory || data.brandValues) totalPages++;
|
|
||||||
if (colors.length > 0) totalPages++;
|
|
||||||
if (hasFonts) totalPages++;
|
|
||||||
if (hasVoice) totalPages++;
|
|
||||||
if (hasLogo || hasDoDont) totalPages++;
|
|
||||||
|
|
||||||
let currentPage = 0;
|
|
||||||
|
|
||||||
// ─── PAGE 1: Cover ───────────────────────────────────────────────────────────
|
|
||||||
currentPage++;
|
|
||||||
|
|
||||||
const M = 12.7; // 0.5" margin in mm
|
|
||||||
const logoBox = 127; // 5" × 5" in mm
|
|
||||||
const clientLogoW = 88.9; // 3.5" in mm
|
|
||||||
const clientLogoH = 38.1; // 1.5" in mm
|
|
||||||
|
|
||||||
// White background
|
|
||||||
doc.setFillColor(255, 255, 255);
|
|
||||||
doc.rect(0, 0, pageWidth, pageHeight, 'F');
|
|
||||||
|
|
||||||
// ── Project logo box (top left) ──────────────────────────────────────────────
|
|
||||||
doc.setDrawColor(210, 210, 210);
|
|
||||||
doc.setLineWidth(0.4);
|
|
||||||
doc.rect(M, M, logoBox, logoBox);
|
|
||||||
|
|
||||||
if (data.projectLogoDataUrl) {
|
|
||||||
const pImg = await loadImage(data.projectLogoDataUrl);
|
|
||||||
if (pImg) {
|
|
||||||
const ratio = pImg.naturalWidth / pImg.naturalHeight;
|
|
||||||
let dW, dH;
|
|
||||||
if (ratio >= 1) {
|
|
||||||
// Landscape/square: fill width first
|
|
||||||
dW = logoBox;
|
|
||||||
dH = dW / ratio;
|
|
||||||
} else {
|
|
||||||
// Portrait: fill height first
|
|
||||||
dH = logoBox;
|
|
||||||
dW = dH * ratio;
|
|
||||||
}
|
|
||||||
doc.addImage(data.projectLogoDataUrl, getImgFormat(data.projectLogoDataUrl), M, M, dW, dH);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
doc.setFontSize(8);
|
|
||||||
doc.setFont('helvetica', 'normal');
|
|
||||||
doc.setTextColor(210, 210, 210);
|
|
||||||
doc.text('PROJECT LOGO', M + logoBox / 2, M + logoBox / 2, { align: 'center' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Dates (top right) ────────────────────────────────────────────────────────
|
|
||||||
const dateColX = M + logoBox + 10;
|
|
||||||
let ty = M + 4;
|
|
||||||
|
|
||||||
doc.setFontSize(7);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.setTextColor(160, 160, 160);
|
|
||||||
doc.setCharSpace(1.2);
|
|
||||||
doc.text('CREATION DATE', dateColX, ty);
|
|
||||||
doc.setCharSpace(0);
|
|
||||||
ty += 6;
|
|
||||||
doc.setFontSize(10);
|
|
||||||
doc.setFont('helvetica', 'normal');
|
|
||||||
doc.setTextColor(30, 30, 30);
|
|
||||||
doc.text(formatDate(data.creationDate), dateColX, ty);
|
|
||||||
ty += 16;
|
|
||||||
|
|
||||||
doc.setFontSize(7);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.setTextColor(160, 160, 160);
|
|
||||||
doc.setCharSpace(1.2);
|
|
||||||
doc.text('REVISION DATE', dateColX, ty);
|
|
||||||
doc.setCharSpace(0);
|
|
||||||
ty += 6;
|
|
||||||
doc.setFontSize(10);
|
|
||||||
doc.setFont('helvetica', 'normal');
|
|
||||||
doc.setTextColor(30, 30, 30);
|
|
||||||
doc.text(formatDate(data.revisionDate), dateColX, ty);
|
|
||||||
|
|
||||||
// ── Separator line ────────────────────────────────────────────────────────────
|
|
||||||
const sepY = M + logoBox + 7;
|
|
||||||
doc.setDrawColor(210, 210, 210);
|
|
||||||
doc.setLineWidth(0.3);
|
|
||||||
doc.line(M, sepY, pageWidth - M, sepY);
|
|
||||||
|
|
||||||
const botY = sepY + 10;
|
|
||||||
const colW = (pageWidth - 2 * M - 10) / 2;
|
|
||||||
const rightColX = M + colW + 10;
|
|
||||||
|
|
||||||
// ── Bottom left: Customer ─────────────────────────────────────────────────────
|
|
||||||
let lY = botY;
|
|
||||||
doc.setFontSize(7);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.setTextColor(160, 160, 160);
|
|
||||||
doc.setCharSpace(1.2);
|
|
||||||
doc.text('CUSTOMER', M, lY);
|
|
||||||
doc.setCharSpace(0);
|
|
||||||
lY += 7;
|
|
||||||
doc.setFontSize(13);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.setTextColor(20, 20, 20);
|
|
||||||
doc.text(data.customerName || '—', M, lY);
|
|
||||||
lY += 8;
|
|
||||||
if (data.streetAddress) {
|
|
||||||
doc.setFontSize(9);
|
|
||||||
doc.setFont('helvetica', 'normal');
|
|
||||||
doc.setTextColor(80, 80, 80);
|
|
||||||
const addrLines = doc.splitTextToSize(data.streetAddress, colW);
|
|
||||||
doc.text(addrLines, M, lY);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Bottom right: Signature / Approval ───────────────────────────────────────
|
|
||||||
let rY = botY;
|
|
||||||
|
|
||||||
// Signature of approval
|
|
||||||
doc.setFontSize(7);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.setTextColor(160, 160, 160);
|
|
||||||
doc.setCharSpace(1.2);
|
|
||||||
doc.text('SIGNATURE OF APPROVAL', rightColX, rY);
|
|
||||||
doc.setCharSpace(0);
|
|
||||||
rY += 12;
|
|
||||||
doc.setDrawColor(180, 180, 180);
|
|
||||||
doc.setLineWidth(0.3);
|
|
||||||
doc.line(rightColX, rY, pageWidth - M, rY);
|
|
||||||
rY += 14;
|
|
||||||
|
|
||||||
// Approved date
|
|
||||||
doc.setFontSize(7);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.setTextColor(160, 160, 160);
|
|
||||||
doc.setCharSpace(1.2);
|
|
||||||
doc.text('APPROVED DATE', rightColX, rY);
|
|
||||||
doc.setCharSpace(0);
|
|
||||||
rY += 6;
|
|
||||||
doc.setFontSize(10);
|
|
||||||
doc.setFont('helvetica', 'normal');
|
|
||||||
doc.setTextColor(30, 30, 30);
|
|
||||||
doc.text(formatDate(data.approvedDate), rightColX, rY);
|
|
||||||
rY += 14;
|
|
||||||
|
|
||||||
// Notes
|
|
||||||
doc.setFontSize(7);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.setTextColor(160, 160, 160);
|
|
||||||
doc.setCharSpace(1.2);
|
|
||||||
doc.text('NOTES', rightColX, rY);
|
|
||||||
doc.setCharSpace(0);
|
|
||||||
rY += 6;
|
|
||||||
if (data.approvalNotes) {
|
|
||||||
doc.setFontSize(9);
|
|
||||||
doc.setFont('helvetica', 'normal');
|
|
||||||
doc.setTextColor(60, 60, 60);
|
|
||||||
const noteLines = doc.splitTextToSize(data.approvalNotes, colW);
|
|
||||||
doc.text(noteLines, rightColX, rY);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Client logo + contact (right-bottom aligned) ──────────────────────────────
|
|
||||||
// Contact text (right-aligned), bottom of page
|
|
||||||
const contactLineH = 5.5;
|
|
||||||
const hasContact = data.clientContactName || data.clientContactEmail || data.clientContactPhone;
|
|
||||||
const contactLines = [data.clientContactName, data.clientContactEmail, data.clientContactPhone].filter(Boolean);
|
|
||||||
const contactBlockH = contactLines.length * contactLineH;
|
|
||||||
const contactStartY = pageHeight - M - contactBlockH;
|
|
||||||
|
|
||||||
if (contactLines.length > 0) {
|
|
||||||
doc.setFontSize(9);
|
|
||||||
doc.setFont('helvetica', 'normal');
|
|
||||||
doc.setTextColor(60, 60, 60);
|
|
||||||
let cy = contactStartY + 4;
|
|
||||||
if (data.clientContactName) {
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.text(data.clientContactName, pageWidth - M, cy, { align: 'right' });
|
|
||||||
cy += contactLineH;
|
|
||||||
}
|
|
||||||
doc.setFont('helvetica', 'normal');
|
|
||||||
if (data.clientContactEmail) { doc.text(data.clientContactEmail, pageWidth - M, cy, { align: 'right' }); cy += contactLineH; }
|
|
||||||
if (data.clientContactPhone) { doc.text(data.clientContactPhone, pageWidth - M, cy, { align: 'right' }); }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Client logo box: 3.5" × 1.5", right-bottom, above contact text
|
|
||||||
const logoBoxGap = hasContact ? contactBlockH + 5 : 4;
|
|
||||||
const clientLogoBoxBottom = pageHeight - M - (hasContact ? contactBlockH + 6 : 2);
|
|
||||||
const clientLogoBoxTop = clientLogoBoxBottom - clientLogoH;
|
|
||||||
const clientLogoBoxLeft = pageWidth - M - clientLogoW;
|
|
||||||
|
|
||||||
doc.setDrawColor(210, 210, 210);
|
|
||||||
doc.setLineWidth(0.3);
|
|
||||||
doc.rect(clientLogoBoxLeft, clientLogoBoxTop, clientLogoW, clientLogoH);
|
|
||||||
|
|
||||||
if (clientLogoDataUrl) {
|
|
||||||
const clImg = await loadImage(clientLogoDataUrl);
|
|
||||||
if (clImg) {
|
|
||||||
const ratio = clImg.naturalWidth / clImg.naturalHeight;
|
|
||||||
// Scale to contain within box
|
|
||||||
let dW = clientLogoW, dH = clientLogoH;
|
|
||||||
if (ratio > clientLogoW / clientLogoH) {
|
|
||||||
dW = clientLogoW;
|
|
||||||
dH = dW / ratio;
|
|
||||||
} else {
|
|
||||||
dH = clientLogoH;
|
|
||||||
dW = dH * ratio;
|
|
||||||
}
|
|
||||||
// Center in box
|
|
||||||
const ox = clientLogoBoxLeft + (clientLogoW - dW) / 2;
|
|
||||||
const oy = clientLogoBoxTop + (clientLogoH - dH) / 2;
|
|
||||||
doc.addImage(clientLogoDataUrl, 'PNG', ox, oy, dW, dH);
|
|
||||||
}
|
|
||||||
} else if (!hasContact) {
|
|
||||||
doc.setFontSize(8);
|
|
||||||
doc.setFont('helvetica', 'normal');
|
|
||||||
doc.setTextColor(210, 210, 210);
|
|
||||||
doc.text('CLIENT LOGO', clientLogoBoxLeft + clientLogoW / 2, clientLogoBoxTop + clientLogoH / 2, { align: 'center' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── PAGE 2: Brand Story + Values ────────────────────────────────────────────
|
|
||||||
if (data.brandStory || data.brandValues) {
|
|
||||||
currentPage++;
|
|
||||||
doc.addPage();
|
|
||||||
addHeader(doc, pageWidth, fourgeLogoImg, logoW, logoH, headerH);
|
|
||||||
let y = headerH + 16;
|
|
||||||
|
|
||||||
if (data.brandStory) {
|
|
||||||
y = sectionHeader(doc, 'Brand Story', y, pageWidth);
|
|
||||||
doc.setFontSize(10);
|
|
||||||
doc.setFont('helvetica', 'normal');
|
|
||||||
doc.setTextColor(60, 60, 60);
|
|
||||||
const lines = doc.splitTextToSize(data.brandStory, pageWidth - 28);
|
|
||||||
doc.text(lines, 14, y);
|
|
||||||
y += lines.length * 6 + 16;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.brandValues) {
|
|
||||||
y = sectionHeader(doc, 'Brand Values', y, pageWidth);
|
|
||||||
const values = data.brandValues.split('\n').map(v => v.trim()).filter(Boolean);
|
|
||||||
values.forEach(val => {
|
|
||||||
doc.setFillColor(245, 165, 35);
|
|
||||||
doc.circle(17, y - 1, 1.2, 'F');
|
|
||||||
doc.setFontSize(10);
|
|
||||||
doc.setFont('helvetica', 'normal');
|
|
||||||
doc.setTextColor(50, 50, 50);
|
|
||||||
doc.text(val, 22, y);
|
|
||||||
y += 8;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
addPageNumber(doc, currentPage, totalPages, pageHeight, pageWidth);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── PAGE 3: Color Palette ────────────────────────────────────────────────────
|
|
||||||
if (colors.length > 0) {
|
|
||||||
currentPage++;
|
|
||||||
doc.addPage();
|
|
||||||
addHeader(doc, pageWidth, fourgeLogoImg, logoW, logoH, headerH);
|
|
||||||
let y = headerH + 16;
|
|
||||||
y = sectionHeader(doc, 'Color Palette', y, pageWidth);
|
|
||||||
|
|
||||||
const swatchW = 52;
|
|
||||||
const swatchH = 40;
|
|
||||||
const cols = 3;
|
|
||||||
const gapX = (pageWidth - 28 - swatchW * cols) / (cols - 1);
|
|
||||||
|
|
||||||
colors.forEach((color, i) => {
|
|
||||||
const col = i % cols;
|
|
||||||
const row = Math.floor(i / cols);
|
|
||||||
const x = 14 + col * (swatchW + gapX);
|
|
||||||
const sy = y + row * (swatchH + 22);
|
|
||||||
|
|
||||||
const rgb = hexToRgb(color.hex || '#cccccc');
|
|
||||||
doc.setFillColor(...rgb);
|
|
||||||
doc.roundedRect(x, sy, swatchW, swatchH, 3, 3, 'F');
|
|
||||||
|
|
||||||
doc.setFontSize(7);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.setTextColor(...(isLight(color.hex || '#cccccc') ? [60, 60, 60] : [220, 220, 220]));
|
|
||||||
doc.text((color.hex || '').toUpperCase(), x + swatchW / 2, sy + swatchH - 5, { align: 'center' });
|
|
||||||
|
|
||||||
doc.setFontSize(9);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.setTextColor(40, 40, 40);
|
|
||||||
doc.text(color.name || 'Unnamed', x + swatchW / 2, sy + swatchH + 8, { align: 'center' });
|
|
||||||
});
|
|
||||||
|
|
||||||
addPageNumber(doc, currentPage, totalPages, pageHeight, pageWidth);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── PAGE 4: Typography ───────────────────────────────────────────────────────
|
|
||||||
if (hasFonts) {
|
|
||||||
currentPage++;
|
|
||||||
doc.addPage();
|
|
||||||
addHeader(doc, pageWidth, fourgeLogoImg, logoW, logoH, headerH);
|
|
||||||
let y = headerH + 16;
|
|
||||||
y = sectionHeader(doc, 'Typography', y, pageWidth);
|
|
||||||
|
|
||||||
const fontItems = [
|
|
||||||
data.primaryFont && ['Primary Font', data.primaryFont],
|
|
||||||
data.secondaryFont && ['Secondary Font', data.secondaryFont],
|
|
||||||
].filter(Boolean);
|
|
||||||
|
|
||||||
fontItems.forEach(([label, fontName]) => {
|
|
||||||
doc.setFillColor(248, 248, 248);
|
|
||||||
doc.setDrawColor(220, 220, 220);
|
|
||||||
doc.setLineWidth(0.3);
|
|
||||||
doc.roundedRect(14, y, pageWidth - 28, 28, 3, 3, 'FD');
|
|
||||||
|
|
||||||
doc.setFontSize(7);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.setTextColor(150, 150, 150);
|
|
||||||
doc.text(label.toUpperCase(), 20, y + 8);
|
|
||||||
|
|
||||||
doc.setFontSize(16);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.setTextColor(30, 30, 30);
|
|
||||||
doc.text(fontName, 20, y + 21);
|
|
||||||
|
|
||||||
y += 36;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (data.fontNotes) {
|
|
||||||
y += 4;
|
|
||||||
y = sectionHeader(doc, 'Usage Notes', y, pageWidth);
|
|
||||||
doc.setFontSize(10);
|
|
||||||
doc.setFont('helvetica', 'normal');
|
|
||||||
doc.setTextColor(60, 60, 60);
|
|
||||||
const lines = doc.splitTextToSize(data.fontNotes, pageWidth - 28);
|
|
||||||
doc.text(lines, 14, y);
|
|
||||||
}
|
|
||||||
|
|
||||||
addPageNumber(doc, currentPage, totalPages, pageHeight, pageWidth);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── PAGE 5: Brand Voice ──────────────────────────────────────────────────────
|
|
||||||
if (hasVoice) {
|
|
||||||
currentPage++;
|
|
||||||
doc.addPage();
|
|
||||||
addHeader(doc, pageWidth, fourgeLogoImg, logoW, logoH, headerH);
|
|
||||||
let y = headerH + 16;
|
|
||||||
y = sectionHeader(doc, 'Brand Voice & Tone', y, pageWidth);
|
|
||||||
|
|
||||||
if (data.brandVoice) {
|
|
||||||
doc.setFontSize(10);
|
|
||||||
doc.setFont('helvetica', 'normal');
|
|
||||||
doc.setTextColor(60, 60, 60);
|
|
||||||
const lines = doc.splitTextToSize(data.brandVoice, pageWidth - 28);
|
|
||||||
doc.text(lines, 14, y);
|
|
||||||
y += lines.length * 6 + 16;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.brandAdjectives) {
|
|
||||||
y = sectionHeader(doc, 'Brand Personality', y, pageWidth);
|
|
||||||
const tags = data.brandAdjectives.split(',').map(t => t.trim()).filter(Boolean);
|
|
||||||
let tx = 14;
|
|
||||||
tags.forEach(tag => {
|
|
||||||
const tw = doc.getTextWidth(tag) + 10;
|
|
||||||
if (tx + tw > pageWidth - 14) { tx = 14; y += 14; }
|
|
||||||
doc.setFillColor(255, 243, 215);
|
|
||||||
doc.setDrawColor(245, 165, 35);
|
|
||||||
doc.setLineWidth(0.3);
|
|
||||||
doc.roundedRect(tx, y - 6, tw, 10, 2, 2, 'FD');
|
|
||||||
doc.setFontSize(8);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.setTextColor(150, 100, 20);
|
|
||||||
doc.text(tag, tx + 5, y + 1);
|
|
||||||
tx += tw + 5;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
addPageNumber(doc, currentPage, totalPages, pageHeight, pageWidth);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── PAGE 6: Logo + Do's & Don'ts ────────────────────────────────────────────
|
|
||||||
if (hasLogo || hasDoDont) {
|
|
||||||
currentPage++;
|
|
||||||
doc.addPage();
|
|
||||||
addHeader(doc, pageWidth, fourgeLogoImg, logoW, logoH, headerH);
|
|
||||||
let y = headerH + 16;
|
|
||||||
|
|
||||||
if (hasLogo) {
|
|
||||||
y = sectionHeader(doc, 'Logo Usage Guidelines', y, pageWidth);
|
|
||||||
doc.setFontSize(10);
|
|
||||||
doc.setFont('helvetica', 'normal');
|
|
||||||
doc.setTextColor(60, 60, 60);
|
|
||||||
const lines = doc.splitTextToSize(data.logoNotes, pageWidth - 28);
|
|
||||||
doc.text(lines, 14, y);
|
|
||||||
y += lines.length * 6 + 16;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasDoDont) {
|
|
||||||
const colW = (pageWidth - 28 - 8) / 2;
|
|
||||||
|
|
||||||
if (data.dos) {
|
|
||||||
doc.setFillColor(22, 163, 74);
|
|
||||||
doc.rect(14, y, 3, 10, 'F');
|
|
||||||
doc.setFontSize(10);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.setTextColor(22, 163, 74);
|
|
||||||
doc.text("DO'S", 21, y + 7);
|
|
||||||
|
|
||||||
const dosLines = data.dos.split('\n').map(l => l.trim()).filter(Boolean);
|
|
||||||
let dy = y + 18;
|
|
||||||
dosLines.forEach(line => {
|
|
||||||
doc.setFillColor(22, 163, 74);
|
|
||||||
doc.circle(16, dy - 1, 1.2, 'F');
|
|
||||||
doc.setFontSize(9);
|
|
||||||
doc.setFont('helvetica', 'normal');
|
|
||||||
doc.setTextColor(50, 50, 50);
|
|
||||||
const wrapped = doc.splitTextToSize(line, colW - 10);
|
|
||||||
doc.text(wrapped, 21, dy);
|
|
||||||
dy += wrapped.length * 5.5 + 3;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.donts) {
|
|
||||||
const startX = 14 + colW + 8;
|
|
||||||
doc.setFillColor(220, 38, 38);
|
|
||||||
doc.rect(startX, y, 3, 10, 'F');
|
|
||||||
doc.setFontSize(10);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.setTextColor(220, 38, 38);
|
|
||||||
doc.text("DON'TS", startX + 7, y + 7);
|
|
||||||
|
|
||||||
const dontsLines = data.donts.split('\n').map(l => l.trim()).filter(Boolean);
|
|
||||||
let dy = y + 18;
|
|
||||||
dontsLines.forEach(line => {
|
|
||||||
doc.setFillColor(220, 38, 38);
|
|
||||||
doc.circle(startX + 2, dy - 1, 1.2, 'F');
|
|
||||||
doc.setFontSize(9);
|
|
||||||
doc.setFont('helvetica', 'normal');
|
|
||||||
doc.setTextColor(50, 50, 50);
|
|
||||||
const wrapped = doc.splitTextToSize(line, colW - 10);
|
|
||||||
doc.text(wrapped, startX + 7, dy);
|
|
||||||
dy += wrapped.length * 5.5 + 3;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
addPageNumber(doc, currentPage, totalPages, pageHeight, pageWidth);
|
|
||||||
}
|
|
||||||
|
|
||||||
const safeName = (data.brandName || 'brand-book').toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
|
||||||
doc.save(`${safeName}-brand-book.pdf`);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
export function formatDateEST(value) {
|
||||||
|
if (!value) return '—';
|
||||||
|
return new Date(value).toLocaleDateString('en-US', {
|
||||||
|
timeZone: 'America/New_York',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDateOnly(value) {
|
||||||
|
if (!value) return null;
|
||||||
|
const [year, month, day] = String(value).split('-').map(Number);
|
||||||
|
if (!year || !month || !day) return null;
|
||||||
|
return new Date(year, month - 1, day);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTodayDateOnlyEST() {
|
||||||
|
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: 'America/New_York',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
}).formatToParts(new Date());
|
||||||
|
const year = parts.find((part) => part.type === 'year')?.value;
|
||||||
|
const month = parts.find((part) => part.type === 'month')?.value;
|
||||||
|
const day = parts.find((part) => part.type === 'day')?.value;
|
||||||
|
return year && month && day ? `${year}-${month}-${day}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addDaysToDateOnly(value, days) {
|
||||||
|
const date = parseDateOnly(value);
|
||||||
|
if (!date) return '';
|
||||||
|
date.setDate(date.getDate() + days);
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateOnly(value, fallback = '—') {
|
||||||
|
const date = parseDateOnly(value);
|
||||||
|
if (!date) return fallback;
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,45 @@
|
|||||||
import { supabase } from './supabase';
|
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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
|||||||
|
const PREFIX = 'fourge_page_cache:';
|
||||||
|
|
||||||
|
export function readPageCache(key, maxAgeMs = 60_000) {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(PREFIX + key);
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!parsed?.savedAt || Date.now() - parsed.savedAt > maxAgeMs) return null;
|
||||||
|
return parsed.data ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writePageCache(key, data) {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(PREFIX + key, JSON.stringify({
|
||||||
|
savedAt: Date.now(),
|
||||||
|
data,
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
// Ignore cache write failures.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { supabase } from './supabase';
|
||||||
|
|
||||||
|
function normalizeProjectName(name = '') {
|
||||||
|
return String(name).trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findOrCreateProject(companyId, projectName, knownProjects = []) {
|
||||||
|
const normalized = normalizeProjectName(projectName);
|
||||||
|
if (!companyId || !normalized) throw new Error('Project company and name are required.');
|
||||||
|
|
||||||
|
const existingKnown = knownProjects.find(project => normalizeProjectName(project.name) === normalized);
|
||||||
|
if (existingKnown) return existingKnown;
|
||||||
|
|
||||||
|
const { data: candidateProjects, error: lookupError } = await supabase
|
||||||
|
.from('projects')
|
||||||
|
.select('id, name, company_id, status')
|
||||||
|
.eq('company_id', companyId)
|
||||||
|
.ilike('name', projectName.trim());
|
||||||
|
if (lookupError) throw lookupError;
|
||||||
|
|
||||||
|
const matched = (candidateProjects || []).find(project => normalizeProjectName(project.name) === normalized);
|
||||||
|
if (matched) return matched;
|
||||||
|
|
||||||
|
const { data: newProject, error: insertError } = await supabase
|
||||||
|
.from('projects')
|
||||||
|
.insert({
|
||||||
|
company_id: companyId,
|
||||||
|
name: projectName.trim(),
|
||||||
|
status: 'active',
|
||||||
|
})
|
||||||
|
.select('id, name, company_id, status')
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!insertError && newProject) return newProject;
|
||||||
|
|
||||||
|
if (insertError?.code === '23505') {
|
||||||
|
const { data: retryProjects, error: retryError } = await supabase
|
||||||
|
.from('projects')
|
||||||
|
.select('id, name, company_id, status')
|
||||||
|
.eq('company_id', companyId)
|
||||||
|
.ilike('name', projectName.trim());
|
||||||
|
if (retryError) throw retryError;
|
||||||
|
const retried = (retryProjects || []).find(project => normalizeProjectName(project.name) === normalized);
|
||||||
|
if (retried) return retried;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw insertError || new Error('Failed to create project.');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTaskForRequest({ projectId, title, requestKey }) {
|
||||||
|
const { data: task, error } = await supabase
|
||||||
|
.from('tasks')
|
||||||
|
.insert({
|
||||||
|
project_id: projectId,
|
||||||
|
title,
|
||||||
|
status: 'not_started',
|
||||||
|
current_version: 0,
|
||||||
|
request_key: requestKey,
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!error && task) return { task, duplicate: false };
|
||||||
|
|
||||||
|
if (error?.code === '23505' && requestKey) {
|
||||||
|
const { data: existingTask, error: existingError } = await supabase
|
||||||
|
.from('tasks')
|
||||||
|
.select('*')
|
||||||
|
.eq('request_key', requestKey)
|
||||||
|
.single();
|
||||||
|
if (existingError) throw existingError;
|
||||||
|
if (existingTask) return { task: existingTask, duplicate: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error || new Error('Failed to create task.');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createInitialSubmissionForRequest({
|
||||||
|
taskId,
|
||||||
|
requestKey,
|
||||||
|
isHot,
|
||||||
|
serviceType,
|
||||||
|
deadline,
|
||||||
|
description,
|
||||||
|
submittedBy,
|
||||||
|
submittedByName,
|
||||||
|
}) {
|
||||||
|
const { data: submission, error } = await supabase
|
||||||
|
.from('submissions')
|
||||||
|
.insert({
|
||||||
|
task_id: taskId,
|
||||||
|
request_key: requestKey,
|
||||||
|
version_number: 0,
|
||||||
|
type: 'initial',
|
||||||
|
is_hot: isHot,
|
||||||
|
service_type: serviceType,
|
||||||
|
deadline: deadline || null,
|
||||||
|
description,
|
||||||
|
submitted_by: submittedBy,
|
||||||
|
submitted_by_name: submittedByName,
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!error && submission) return { submission, duplicate: false };
|
||||||
|
|
||||||
|
if (error?.code === '23505' && requestKey) {
|
||||||
|
const { data: existingSubmission, error: existingError } = await supabase
|
||||||
|
.from('submissions')
|
||||||
|
.select('*')
|
||||||
|
.eq('request_key', requestKey)
|
||||||
|
.single();
|
||||||
|
if (existingError) throw existingError;
|
||||||
|
if (existingSubmission) return { submission: existingSubmission, duplicate: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error || new Error('Failed to create submission.');
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { supabase } from './supabase';
|
||||||
|
|
||||||
|
export async function syncSeafileFolders() {
|
||||||
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
if (!session?.access_token) return { skipped: true };
|
||||||
|
|
||||||
|
const response = await fetch('/api/seafile?action=sync-folders', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${session.access_token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) throw new Error(data.error || 'Failed to sync Seafile folders.');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -1,441 +0,0 @@
|
|||||||
import jsPDF from 'jspdf';
|
|
||||||
|
|
||||||
// Letter landscape: 792 x 612 pt
|
|
||||||
const W = 792;
|
|
||||||
const H = 612;
|
|
||||||
const MARGIN = 36;
|
|
||||||
const ACCENT = [245, 165, 35];
|
|
||||||
const DARK = [18, 18, 18];
|
|
||||||
const HEADER_H = 32;
|
|
||||||
|
|
||||||
// Accepts File, data URL string, or https URL string — returns data URL
|
|
||||||
async function resolvePhoto(source) {
|
|
||||||
if (!source) return null;
|
|
||||||
if (source instanceof File) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = (e) => resolve(e.target.result);
|
|
||||||
reader.onerror = () => resolve(null);
|
|
||||||
reader.readAsDataURL(source);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (typeof source === 'string') {
|
|
||||||
if (source.startsWith('data:')) return source;
|
|
||||||
try {
|
|
||||||
const resp = await fetch(source);
|
|
||||||
const blob = await resp.blob();
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = (e) => resolve(e.target.result);
|
|
||||||
reader.onerror = () => resolve(null);
|
|
||||||
reader.readAsDataURL(blob);
|
|
||||||
});
|
|
||||||
} catch { return null; }
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadImage(src) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
if (!src) return resolve(null);
|
|
||||||
const img = new Image();
|
|
||||||
img.crossOrigin = 'anonymous';
|
|
||||||
img.onload = () => resolve(img);
|
|
||||||
img.onerror = () => resolve(null);
|
|
||||||
img.src = src;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function addHeader(doc, logo, logoW, logoH, title, pageNum, totalPages) {
|
|
||||||
doc.setFillColor(...DARK);
|
|
||||||
doc.rect(0, 0, W, HEADER_H, 'F');
|
|
||||||
doc.setFillColor(...ACCENT);
|
|
||||||
doc.rect(0, 0, 4, HEADER_H, 'F');
|
|
||||||
|
|
||||||
if (logo) {
|
|
||||||
doc.addImage(logo, 'PNG', MARGIN, (HEADER_H - logoH) / 2, logoW, logoH);
|
|
||||||
} else {
|
|
||||||
doc.setFontSize(9);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.setTextColor(255, 255, 255);
|
|
||||||
doc.text('FOURGE BRANDING', MARGIN, HEADER_H / 2 + 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
doc.setFontSize(9);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.setTextColor(200, 200, 200);
|
|
||||||
doc.text(title.toUpperCase(), W / 2, HEADER_H / 2 + 3, { align: 'center' });
|
|
||||||
|
|
||||||
doc.setFontSize(8);
|
|
||||||
doc.setFont('helvetica', 'normal');
|
|
||||||
doc.setTextColor(120, 120, 120);
|
|
||||||
doc.text(`${pageNum} / ${totalPages}`, W - MARGIN, HEADER_H / 2 + 3, { align: 'right' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function addFooter(doc, clientName, date) {
|
|
||||||
doc.setDrawColor(60, 60, 60);
|
|
||||||
doc.setLineWidth(0.3);
|
|
||||||
doc.line(MARGIN, H - 22, W - MARGIN, H - 22);
|
|
||||||
doc.setFontSize(7);
|
|
||||||
doc.setFont('helvetica', 'normal');
|
|
||||||
doc.setTextColor(130, 130, 130);
|
|
||||||
doc.text(clientName, MARGIN, H - 12);
|
|
||||||
doc.text('hello@fourgebranding.com · www.fourgebranding.com', W / 2, H - 12, { align: 'center' });
|
|
||||||
if (date) doc.text(date, W - MARGIN, H - 12, { align: 'right' });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateBrandBookEditorPDF(data) {
|
|
||||||
const { clientName, projectName, siteAddress, bookDate, preparedBy, revision,
|
|
||||||
siteMapSource, signs, surveyPhotoSources } = data;
|
|
||||||
|
|
||||||
const doc = new jsPDF({ orientation: 'landscape', format: 'letter', unit: 'pt' });
|
|
||||||
|
|
||||||
// Load assets
|
|
||||||
const logo = await loadImage('/fourge-logo.png');
|
|
||||||
const logoW = 40;
|
|
||||||
const logoH = logo ? (logoW / (logo.naturalWidth / logo.naturalHeight)) : 10;
|
|
||||||
|
|
||||||
// Resolve all photos to data URLs up front
|
|
||||||
const siteMapDataUrl = await resolvePhoto(siteMapSource);
|
|
||||||
const signPhotoDataUrls = await Promise.all(signs.map(s => resolvePhoto(s.photoSource)));
|
|
||||||
const surveyPhotoDataUrls = await Promise.all((surveyPhotoSources || []).map(s => resolvePhoto(s)));
|
|
||||||
|
|
||||||
// Count pages
|
|
||||||
let totalPages = 1; // cover
|
|
||||||
totalPages++; // sign inventory
|
|
||||||
totalPages += signs.length;
|
|
||||||
if (surveyPhotoDataUrls.some(Boolean)) totalPages++;
|
|
||||||
|
|
||||||
let pageNum = 0;
|
|
||||||
const displayDate = bookDate
|
|
||||||
? new Date(bookDate + 'T12:00:00').toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
|
|
||||||
: '';
|
|
||||||
|
|
||||||
// ─── COVER PAGE ──────────────────────────────────────────────────────────────
|
|
||||||
pageNum++;
|
|
||||||
doc.setFillColor(...DARK);
|
|
||||||
doc.rect(0, 0, W, H, 'F');
|
|
||||||
doc.setFillColor(...ACCENT);
|
|
||||||
doc.rect(0, 0, W, 4, 'F');
|
|
||||||
doc.rect(0, H - 4, W, 4, 'F');
|
|
||||||
|
|
||||||
if (logo) {
|
|
||||||
doc.addImage(logo, 'PNG', MARGIN, 22, logoW, logoH);
|
|
||||||
} else {
|
|
||||||
doc.setFontSize(10);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.setTextColor(255, 255, 255);
|
|
||||||
doc.text('FOURGE BRANDING', MARGIN, 38);
|
|
||||||
}
|
|
||||||
|
|
||||||
const cy = H / 2;
|
|
||||||
|
|
||||||
doc.setFontSize(9);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.setTextColor(...ACCENT);
|
|
||||||
doc.setCharSpace(3);
|
|
||||||
doc.text('BRAND BOOK', W / 2, cy - 54, { align: 'center' });
|
|
||||||
doc.setCharSpace(0);
|
|
||||||
|
|
||||||
doc.setDrawColor(...ACCENT);
|
|
||||||
doc.setLineWidth(0.5);
|
|
||||||
doc.line(W / 2 - 40, cy - 46, W / 2 + 40, cy - 46);
|
|
||||||
|
|
||||||
doc.setFontSize(36);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.setTextColor(255, 255, 255);
|
|
||||||
doc.text(clientName || 'Client Name', W / 2, cy - 18, { align: 'center' });
|
|
||||||
|
|
||||||
if (projectName) {
|
|
||||||
doc.setFontSize(14);
|
|
||||||
doc.setFont('helvetica', 'normal');
|
|
||||||
doc.setTextColor(160, 160, 160);
|
|
||||||
doc.text(projectName, W / 2, cy + 12, { align: 'center' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const metaParts = [];
|
|
||||||
if (siteAddress) metaParts.push(siteAddress);
|
|
||||||
if (displayDate) metaParts.push(displayDate);
|
|
||||||
if (preparedBy) metaParts.push(`Prepared by ${preparedBy}`);
|
|
||||||
if (metaParts.length > 0) {
|
|
||||||
doc.setFontSize(9);
|
|
||||||
doc.setFont('helvetica', 'normal');
|
|
||||||
doc.setTextColor(100, 100, 100);
|
|
||||||
doc.text(metaParts.join(' · '), W / 2, cy + 36, { align: 'center' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Revision badge bottom right of cover
|
|
||||||
const rev = String(revision || '01').padStart(2, '0');
|
|
||||||
doc.setFontSize(10);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.setTextColor(80, 80, 80);
|
|
||||||
doc.text(`R${rev}`, W - MARGIN, cy + 36, { align: 'right' });
|
|
||||||
|
|
||||||
// ─── PAGE 2: SIGN INVENTORY ───────────────────────────────────────────────────
|
|
||||||
pageNum++;
|
|
||||||
doc.addPage();
|
|
||||||
addHeader(doc, logo, logoW, logoH, 'Sign Inventory', pageNum, totalPages);
|
|
||||||
addFooter(doc, clientName, displayDate);
|
|
||||||
|
|
||||||
const contentTop = HEADER_H + 16;
|
|
||||||
const contentBottom = H - 30;
|
|
||||||
const contentH = contentBottom - contentTop;
|
|
||||||
|
|
||||||
if (siteMapDataUrl) {
|
|
||||||
const mapW = (W - MARGIN * 2 - 16) * 0.55;
|
|
||||||
const mapH = contentH;
|
|
||||||
const mapX = MARGIN;
|
|
||||||
const mapY = contentTop;
|
|
||||||
|
|
||||||
const siteImg = await loadImage(siteMapDataUrl);
|
|
||||||
if (siteImg) {
|
|
||||||
const imgAspect = siteImg.naturalWidth / siteImg.naturalHeight;
|
|
||||||
const boxAspect = mapW / mapH;
|
|
||||||
let drawW, drawH, drawX, drawY;
|
|
||||||
if (imgAspect > boxAspect) {
|
|
||||||
drawW = mapW; drawH = mapW / imgAspect;
|
|
||||||
drawX = mapX; drawY = mapY + (mapH - drawH) / 2;
|
|
||||||
} else {
|
|
||||||
drawH = mapH; drawW = mapH * imgAspect;
|
|
||||||
drawX = mapX + (mapW - drawW) / 2; drawY = mapY;
|
|
||||||
}
|
|
||||||
doc.setDrawColor(80, 80, 80);
|
|
||||||
doc.setLineWidth(0.5);
|
|
||||||
doc.rect(mapX, mapY, mapW, mapH);
|
|
||||||
doc.addImage(siteMapDataUrl, drawX, drawY, drawW, drawH);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tableX = MARGIN + mapW + 16;
|
|
||||||
const tableW = W - MARGIN - tableX;
|
|
||||||
drawInventoryTable(doc, signs, tableX, contentTop, tableW, contentH);
|
|
||||||
} else {
|
|
||||||
drawInventoryTable(doc, signs, MARGIN, contentTop, W - MARGIN * 2, contentH);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── SIGN PAGES ───────────────────────────────────────────────────────────────
|
|
||||||
for (let i = 0; i < signs.length; i++) {
|
|
||||||
const sign = signs[i];
|
|
||||||
const photoDataUrl = signPhotoDataUrls[i];
|
|
||||||
pageNum++;
|
|
||||||
doc.addPage();
|
|
||||||
const signLabel = `Sign ${sign.signNumber || (i + 1)} — ${sign.type || 'Sign'}`;
|
|
||||||
addHeader(doc, logo, logoW, logoH, signLabel, pageNum, totalPages);
|
|
||||||
addFooter(doc, clientName, displayDate);
|
|
||||||
|
|
||||||
const top = HEADER_H + 16;
|
|
||||||
const bottom = H - 30;
|
|
||||||
const availH = bottom - top;
|
|
||||||
const photoW = (W - MARGIN * 2 - 20) * 0.45;
|
|
||||||
const specsX = MARGIN + photoW + 20;
|
|
||||||
const specsW = W - MARGIN - specsX;
|
|
||||||
|
|
||||||
if (photoDataUrl) {
|
|
||||||
const photoImg = await loadImage(photoDataUrl);
|
|
||||||
if (photoImg) {
|
|
||||||
const imgAspect = photoImg.naturalWidth / photoImg.naturalHeight;
|
|
||||||
const boxAspect = photoW / availH;
|
|
||||||
let dw, dh, dx, dy;
|
|
||||||
if (imgAspect > boxAspect) {
|
|
||||||
dw = photoW; dh = photoW / imgAspect;
|
|
||||||
dx = MARGIN; dy = top + (availH - dh) / 2;
|
|
||||||
} else {
|
|
||||||
dh = availH; dw = availH * imgAspect;
|
|
||||||
dx = MARGIN + (photoW - dw) / 2; dy = top;
|
|
||||||
}
|
|
||||||
doc.setFillColor(30, 30, 30);
|
|
||||||
doc.rect(MARGIN, top, photoW, availH, 'F');
|
|
||||||
doc.addImage(photoDataUrl, dx, dy, dw, dh);
|
|
||||||
doc.setDrawColor(60, 60, 60);
|
|
||||||
doc.setLineWidth(0.5);
|
|
||||||
doc.rect(MARGIN, top, photoW, availH);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
doc.setFillColor(30, 30, 30);
|
|
||||||
doc.rect(MARGIN, top, photoW, availH, 'F');
|
|
||||||
doc.setFontSize(10);
|
|
||||||
doc.setFont('helvetica', 'normal');
|
|
||||||
doc.setTextColor(80, 80, 80);
|
|
||||||
doc.text('No Photo', MARGIN + photoW / 2, top + availH / 2, { align: 'center' });
|
|
||||||
doc.setDrawColor(60, 60, 60);
|
|
||||||
doc.setLineWidth(0.5);
|
|
||||||
doc.rect(MARGIN, top, photoW, availH);
|
|
||||||
}
|
|
||||||
|
|
||||||
let sy = top;
|
|
||||||
|
|
||||||
doc.setFillColor(...ACCENT);
|
|
||||||
doc.roundedRect(specsX, sy, 44, 18, 3, 3, 'F');
|
|
||||||
doc.setFontSize(10);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.setTextColor(...DARK);
|
|
||||||
doc.text(`#${sign.signNumber || (i + 1)}`, specsX + 22, sy + 12, { align: 'center' });
|
|
||||||
sy += 26;
|
|
||||||
|
|
||||||
doc.setFontSize(16);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.setTextColor(30, 30, 30);
|
|
||||||
doc.text(sign.type || 'Sign Type', specsX, sy);
|
|
||||||
sy += 20;
|
|
||||||
|
|
||||||
doc.setDrawColor(...ACCENT);
|
|
||||||
doc.setLineWidth(1);
|
|
||||||
doc.line(specsX, sy, specsX + specsW, sy);
|
|
||||||
sy += 12;
|
|
||||||
|
|
||||||
const specs = [
|
|
||||||
['Location', sign.location || '—'],
|
|
||||||
['Dimensions', sign.width && sign.height ? `${sign.width}" W × ${sign.height}" H` : '—'],
|
|
||||||
['Material', sign.material || '— (placeholder)'],
|
|
||||||
['Illumination', sign.illumination || '— (placeholder)'],
|
|
||||||
['Condition', sign.condition || '— (placeholder)'],
|
|
||||||
['Mount Type', sign.mountType || '— (placeholder)'],
|
|
||||||
];
|
|
||||||
|
|
||||||
specs.forEach(([label, value]) => {
|
|
||||||
doc.setFontSize(7);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.setTextColor(150, 150, 150);
|
|
||||||
doc.text(label.toUpperCase(), specsX, sy);
|
|
||||||
sy += 9;
|
|
||||||
doc.setFontSize(10);
|
|
||||||
doc.setFont('helvetica', 'normal');
|
|
||||||
doc.setTextColor(40, 40, 40);
|
|
||||||
const wrapped = doc.splitTextToSize(value, specsW);
|
|
||||||
doc.text(wrapped, specsX, sy);
|
|
||||||
sy += wrapped.length * 6 + 10;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (sign.notes) {
|
|
||||||
doc.setFontSize(7);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.setTextColor(150, 150, 150);
|
|
||||||
doc.text('NOTES', specsX, sy);
|
|
||||||
sy += 9;
|
|
||||||
doc.setFontSize(9);
|
|
||||||
doc.setFont('helvetica', 'normal');
|
|
||||||
doc.setTextColor(80, 80, 80);
|
|
||||||
const noteLines = doc.splitTextToSize(sign.notes, specsW);
|
|
||||||
doc.text(noteLines, specsX, sy);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── SURVEY PHOTOS PAGE ───────────────────────────────────────────────────────
|
|
||||||
const validSurveyPhotos = surveyPhotoDataUrls.filter(Boolean);
|
|
||||||
if (validSurveyPhotos.length > 0) {
|
|
||||||
pageNum++;
|
|
||||||
doc.addPage();
|
|
||||||
addHeader(doc, logo, logoW, logoH, 'Site Photos', pageNum, totalPages);
|
|
||||||
addFooter(doc, clientName, displayDate);
|
|
||||||
|
|
||||||
const top = HEADER_H + 14;
|
|
||||||
const bottom = H - 30;
|
|
||||||
const availW = W - MARGIN * 2;
|
|
||||||
const cols = 4;
|
|
||||||
const rows = 3;
|
|
||||||
const gapX = 10;
|
|
||||||
const gapY = 10;
|
|
||||||
const thumbW = (availW - gapX * (cols - 1)) / cols;
|
|
||||||
const thumbH = (bottom - top - gapY * (rows - 1)) / rows;
|
|
||||||
|
|
||||||
for (let i = 0; i < Math.min(validSurveyPhotos.length, cols * rows); i++) {
|
|
||||||
const dataUrl = validSurveyPhotos[i];
|
|
||||||
const col = i % cols;
|
|
||||||
const row = Math.floor(i / cols);
|
|
||||||
const tx = MARGIN + col * (thumbW + gapX);
|
|
||||||
const ty = top + row * (thumbH + gapY);
|
|
||||||
|
|
||||||
doc.setFillColor(30, 30, 30);
|
|
||||||
doc.rect(tx, ty, thumbW, thumbH, 'F');
|
|
||||||
|
|
||||||
const img = await loadImage(dataUrl);
|
|
||||||
if (img) {
|
|
||||||
const aspect = img.naturalWidth / img.naturalHeight;
|
|
||||||
const boxAspect = thumbW / thumbH;
|
|
||||||
let dw, dh, dx, dy;
|
|
||||||
if (aspect > boxAspect) {
|
|
||||||
dw = thumbW; dh = thumbW / aspect; dx = tx; dy = ty + (thumbH - dh) / 2;
|
|
||||||
} else {
|
|
||||||
dh = thumbH; dw = thumbH * aspect; dx = tx + (thumbW - dw) / 2; dy = ty;
|
|
||||||
}
|
|
||||||
doc.addImage(dataUrl, dx, dy, dw, dh);
|
|
||||||
}
|
|
||||||
doc.setDrawColor(60, 60, 60);
|
|
||||||
doc.setLineWidth(0.3);
|
|
||||||
doc.rect(tx, ty, thumbW, thumbH);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (validSurveyPhotos.length > cols * rows) {
|
|
||||||
doc.setFontSize(8);
|
|
||||||
doc.setFont('helvetica', 'italic');
|
|
||||||
doc.setTextColor(120, 120, 120);
|
|
||||||
doc.text(`+${validSurveyPhotos.length - cols * rows} more photos not shown`, W - MARGIN, bottom + 8, { align: 'right' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const safePart = (str) => (str || '').replace(/[^a-zA-Z0-9 ]/g, '').trim().replace(/\s+/g, ' ');
|
|
||||||
const filename = [safePart(projectName), safePart(siteAddress), `R${rev}`].filter(Boolean).join('.');
|
|
||||||
doc.save(`${filename || 'BrandBook'}.pdf`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawInventoryTable(doc, signs, x, y, w, h) {
|
|
||||||
const colDefs = [
|
|
||||||
{ label: '#', flex: 0.5 },
|
|
||||||
{ label: 'Type', flex: 1.5 },
|
|
||||||
{ label: 'Location', flex: 2 },
|
|
||||||
{ label: 'Dimensions', flex: 1.2 },
|
|
||||||
{ label: 'Notes', flex: 2 },
|
|
||||||
];
|
|
||||||
const totalFlex = colDefs.reduce((s, c) => s + c.flex, 0);
|
|
||||||
const cols = colDefs.map(c => ({ ...c, w: (c.flex / totalFlex) * w }));
|
|
||||||
|
|
||||||
const rowH = 18;
|
|
||||||
doc.setFillColor(30, 30, 30);
|
|
||||||
doc.rect(x, y, w, rowH, 'F');
|
|
||||||
doc.setFontSize(7);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.setTextColor(...ACCENT);
|
|
||||||
|
|
||||||
let cx = x;
|
|
||||||
cols.forEach(col => { doc.text(col.label, cx + 5, y + 12); cx += col.w; });
|
|
||||||
|
|
||||||
let ry = y + rowH;
|
|
||||||
signs.forEach((sign, i) => {
|
|
||||||
if (ry > y + h - rowH) return;
|
|
||||||
const rowData = [
|
|
||||||
sign.signNumber || String(i + 1),
|
|
||||||
sign.type || '—',
|
|
||||||
sign.location || '—',
|
|
||||||
sign.width && sign.height ? `${sign.width}" × ${sign.height}"` : '—',
|
|
||||||
sign.notes || '',
|
|
||||||
];
|
|
||||||
|
|
||||||
doc.setFillColor(i % 2 === 0 ? 248 : 242, i % 2 === 0 ? 248 : 242, i % 2 === 0 ? 248 : 242);
|
|
||||||
doc.rect(x, ry, w, rowH, 'F');
|
|
||||||
doc.setFontSize(8);
|
|
||||||
doc.setFont('helvetica', 'normal');
|
|
||||||
doc.setTextColor(40, 40, 40);
|
|
||||||
|
|
||||||
cx = x;
|
|
||||||
cols.forEach((col, ci) => {
|
|
||||||
const truncated = doc.splitTextToSize(rowData[ci] || '', col.w - 8)[0] || '';
|
|
||||||
doc.text(truncated, cx + 5, ry + 12);
|
|
||||||
cx += col.w;
|
|
||||||
});
|
|
||||||
|
|
||||||
doc.setDrawColor(220, 220, 220);
|
|
||||||
doc.setLineWidth(0.2);
|
|
||||||
doc.line(x, ry + rowH, x + w, ry + rowH);
|
|
||||||
ry += rowH;
|
|
||||||
});
|
|
||||||
|
|
||||||
doc.setDrawColor(180, 180, 180);
|
|
||||||
doc.setLineWidth(0.5);
|
|
||||||
doc.rect(x, y, w, ry - y);
|
|
||||||
|
|
||||||
if (signs.length === 0) {
|
|
||||||
doc.setFontSize(9);
|
|
||||||
doc.setFont('helvetica', 'italic');
|
|
||||||
doc.setTextColor(160, 160, 160);
|
|
||||||
doc.text('No signs added yet', x + w / 2, y + rowH + 16, { align: 'center' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import jsPDF from 'jspdf';
|
||||||
|
|
||||||
|
const W = 792;
|
||||||
|
const H = 612;
|
||||||
|
const MARGIN = 36;
|
||||||
|
const ACCENT = [245, 165, 35];
|
||||||
|
const DARK = [18, 18, 18];
|
||||||
|
const HEADER_H = 64;
|
||||||
|
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
const d = new Date(`${dateStr}T12:00:00`);
|
||||||
|
return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function blobToDataUrl(blob) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event) => resolve(event.target?.result || null);
|
||||||
|
reader.onerror = () => resolve(null);
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolvePhoto(source) {
|
||||||
|
if (!source) return null;
|
||||||
|
if (source instanceof File || source instanceof Blob) {
|
||||||
|
return blobToDataUrl(source);
|
||||||
|
}
|
||||||
|
if (typeof source === 'string') return source.startsWith('data:') ? source : null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFormat(dataUrl) {
|
||||||
|
return dataUrl?.startsWith('data:image/png') ? 'PNG' : 'JPEG';
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadImage(src) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (!src) return resolve(null);
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => resolve(img);
|
||||||
|
img.onerror = () => resolve(null);
|
||||||
|
img.src = src;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const PX_PER_PT = 300 / 72;
|
||||||
|
|
||||||
|
function toPrintJpeg(dataUrl, img, displayPtW, displayPtH, quality = 0.82) {
|
||||||
|
if (!dataUrl || dataUrl.startsWith('data:image/png')) return dataUrl;
|
||||||
|
const tW = Math.round(displayPtW * PX_PER_PT);
|
||||||
|
const tH = Math.round(displayPtH * PX_PER_PT);
|
||||||
|
if (img.naturalWidth <= tW && img.naturalHeight <= tH) return dataUrl;
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = tW;
|
||||||
|
canvas.height = tH;
|
||||||
|
canvas.getContext('2d').drawImage(img, 0, 0, tW, tH);
|
||||||
|
return canvas.toDataURL('image/jpeg', quality);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addHeader(doc, title, pageNum, totalPages) {
|
||||||
|
doc.setFillColor(255, 255, 255);
|
||||||
|
doc.rect(0, 0, W, HEADER_H, 'F');
|
||||||
|
doc.setFillColor(...ACCENT);
|
||||||
|
doc.rect(0, 0, 4, HEADER_H, 'F');
|
||||||
|
doc.setDrawColor(220, 220, 220);
|
||||||
|
doc.setLineWidth(0.3);
|
||||||
|
doc.line(0, HEADER_H, W, HEADER_H);
|
||||||
|
|
||||||
|
doc.setFontSize(10);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setTextColor(...DARK);
|
||||||
|
doc.text(title.toUpperCase(), MARGIN, HEADER_H / 2 + 4);
|
||||||
|
|
||||||
|
doc.setFontSize(8);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setTextColor(140, 140, 140);
|
||||||
|
doc.text(`${pageNum} / ${totalPages}`, W - MARGIN, HEADER_H / 2 + 4, { align: 'right' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawCoverImage(doc, dataUrl, img, x, y, w, h) {
|
||||||
|
if (!dataUrl || !img) return;
|
||||||
|
const srcAspect = img.naturalWidth / img.naturalHeight;
|
||||||
|
const boxAspect = w / h;
|
||||||
|
let drawW;
|
||||||
|
let drawH;
|
||||||
|
let drawX;
|
||||||
|
let drawY;
|
||||||
|
|
||||||
|
if (srcAspect > boxAspect) {
|
||||||
|
drawW = w;
|
||||||
|
drawH = w / srcAspect;
|
||||||
|
drawX = x;
|
||||||
|
drawY = y + (h - drawH) / 2;
|
||||||
|
} else {
|
||||||
|
drawH = h;
|
||||||
|
drawW = h * srcAspect;
|
||||||
|
drawX = x + (w - drawW) / 2;
|
||||||
|
drawY = y;
|
||||||
|
}
|
||||||
|
|
||||||
|
const printableDataUrl = toPrintJpeg(dataUrl, img, drawW, drawH);
|
||||||
|
doc.addImage(printableDataUrl, getFormat(printableDataUrl), drawX, drawY, drawW, drawH);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateSurveyMakerPdf(data) {
|
||||||
|
const { clientName, siteAddress, surveyDate, preparedBy, projectName, signs } = data;
|
||||||
|
const doc = new jsPDF({ orientation: 'landscape', format: 'letter', unit: 'pt', compress: true });
|
||||||
|
|
||||||
|
const signPhotos = await Promise.all((signs || []).map(async (sign) => {
|
||||||
|
const mainDataUrl = await resolvePhoto(sign.mainPhoto);
|
||||||
|
const contextDataUrls = await Promise.all([
|
||||||
|
resolvePhoto(sign.contextPhoto1),
|
||||||
|
resolvePhoto(sign.contextPhoto2),
|
||||||
|
resolvePhoto(sign.contextPhoto3),
|
||||||
|
]);
|
||||||
|
const mainImage = mainDataUrl ? await loadImage(mainDataUrl) : null;
|
||||||
|
const contextImages = await Promise.all(contextDataUrls.map(async (dataUrl) => (dataUrl ? loadImage(dataUrl) : null)));
|
||||||
|
|
||||||
|
return { mainDataUrl, mainImage, contextDataUrls, contextImages };
|
||||||
|
}));
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, 1 + signs.length);
|
||||||
|
|
||||||
|
doc.setFillColor(255, 255, 255);
|
||||||
|
doc.rect(0, 0, W, H, 'F');
|
||||||
|
doc.setFillColor(...ACCENT);
|
||||||
|
doc.rect(0, 0, W, 5, 'F');
|
||||||
|
doc.rect(0, H - 5, W, 5, 'F');
|
||||||
|
|
||||||
|
doc.setFontSize(30);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setTextColor(...DARK);
|
||||||
|
doc.text(projectName || 'Sign Survey', MARGIN, 88);
|
||||||
|
|
||||||
|
const details = [
|
||||||
|
['Client', clientName || '-'],
|
||||||
|
['Site Address', siteAddress || '-'],
|
||||||
|
['Survey Date', formatDate(surveyDate)],
|
||||||
|
['Prepared By', preparedBy || '-'],
|
||||||
|
['Sign Count', String(signs.length)],
|
||||||
|
];
|
||||||
|
|
||||||
|
let y = 148;
|
||||||
|
details.forEach(([label, value]) => {
|
||||||
|
doc.setFontSize(9);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setTextColor(150, 150, 150);
|
||||||
|
doc.text(label.toUpperCase(), MARGIN, y);
|
||||||
|
y += 16;
|
||||||
|
doc.setFontSize(14);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setTextColor(40, 40, 40);
|
||||||
|
const lines = doc.splitTextToSize(value, 320);
|
||||||
|
doc.text(lines, MARGIN, y);
|
||||||
|
y += lines.length * 16 + 18;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (signPhotos[0]?.mainDataUrl && signPhotos[0]?.mainImage) {
|
||||||
|
const photoX = 410;
|
||||||
|
const photoY = 110;
|
||||||
|
const photoW = W - photoX - MARGIN;
|
||||||
|
const photoH = 360;
|
||||||
|
drawCoverImage(doc, signPhotos[0].mainDataUrl, signPhotos[0].mainImage, photoX, photoY, photoW, photoH);
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.setFontSize(8);
|
||||||
|
doc.setTextColor(140, 140, 140);
|
||||||
|
doc.text('1 / ' + totalPages, W - MARGIN, H - 18, { align: 'right' });
|
||||||
|
|
||||||
|
for (let index = 0; index < signs.length; index += 1) {
|
||||||
|
const sign = signs[index];
|
||||||
|
const photo = signPhotos[index];
|
||||||
|
doc.addPage();
|
||||||
|
addHeader(doc, sign.signName || `Sign ${index + 1}`, index + 2, totalPages);
|
||||||
|
|
||||||
|
const top = HEADER_H + 20;
|
||||||
|
const leftW = 270;
|
||||||
|
const rightX = MARGIN + leftW + 20;
|
||||||
|
const rightW = W - rightX - MARGIN;
|
||||||
|
const mainPhotoH = 280;
|
||||||
|
const contextGap = 10;
|
||||||
|
const contextPhotoY = top + mainPhotoH + 12;
|
||||||
|
const contextPhotoW = (rightW - contextGap * 2) / 3;
|
||||||
|
const contextPhotoH = H - contextPhotoY - 36;
|
||||||
|
|
||||||
|
doc.setFontSize(18);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setTextColor(...DARK);
|
||||||
|
doc.text(sign.signName || `Sign ${index + 1}`, MARGIN, top + 10);
|
||||||
|
|
||||||
|
let textY = top + 42;
|
||||||
|
const blocks = [
|
||||||
|
['Measurements', sign.measurements || '-'],
|
||||||
|
['Notes', sign.notes || '-'],
|
||||||
|
];
|
||||||
|
|
||||||
|
blocks.forEach(([label, value]) => {
|
||||||
|
doc.setFontSize(9);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setTextColor(150, 150, 150);
|
||||||
|
doc.text(label.toUpperCase(), MARGIN, textY);
|
||||||
|
textY += 16;
|
||||||
|
doc.setFontSize(11);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setTextColor(50, 50, 50);
|
||||||
|
const lines = doc.splitTextToSize(value, leftW);
|
||||||
|
doc.text(lines, MARGIN, textY);
|
||||||
|
textY += lines.length * 14 + 24;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (photo?.mainDataUrl && photo?.mainImage) {
|
||||||
|
drawCoverImage(doc, photo.mainDataUrl, photo.mainImage, rightX, top, rightW, mainPhotoH);
|
||||||
|
} else {
|
||||||
|
doc.setFontSize(10);
|
||||||
|
doc.setTextColor(170, 170, 170);
|
||||||
|
doc.text('No Main Photo', rightX + rightW / 2, top + mainPhotoH / 2, { align: 'center' });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let contextIndex = 0; contextIndex < 3; contextIndex += 1) {
|
||||||
|
const x = rightX + contextIndex * (contextPhotoW + contextGap);
|
||||||
|
|
||||||
|
const contextDataUrl = photo?.contextDataUrls?.[contextIndex];
|
||||||
|
const contextImage = photo?.contextImages?.[contextIndex];
|
||||||
|
if (contextDataUrl && contextImage) {
|
||||||
|
drawCoverImage(doc, contextDataUrl, contextImage, x, contextPhotoY, contextPhotoW, contextPhotoH);
|
||||||
|
} else {
|
||||||
|
doc.setFontSize(9);
|
||||||
|
doc.setTextColor(180, 180, 180);
|
||||||
|
doc.text(`Context ${contextIndex + 1}`, x + contextPhotoW / 2, contextPhotoY + contextPhotoH / 2, { align: 'center' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const safe = (value) => (value || '').replace(/[^a-zA-Z0-9 ]/g, '').trim().replace(/\s+/g, ' ');
|
||||||
|
const filename = [safe(projectName || 'Survey'), safe(clientName), formatDate(surveyDate).replace(/[^a-zA-Z0-9]/g, '')].filter(Boolean).join('.');
|
||||||
|
doc.save(`${filename || 'SurveyMaker'}.pdf`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export function getCurrentVersionForTask(task, submissions) {
|
||||||
|
return Math.max(
|
||||||
|
task?.current_version || 0,
|
||||||
|
...((submissions || []).map(submission => submission.version_number || 0))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDeadlineSourceSubmission(task, submissions) {
|
||||||
|
const taskSubs = (submissions || []).filter(submission => submission?.task_id === task?.id);
|
||||||
|
if (taskSubs.length === 0) return null;
|
||||||
|
|
||||||
|
const deadlineRow = [...taskSubs]
|
||||||
|
.filter(submission => submission.deadline)
|
||||||
|
.sort((a, b) => {
|
||||||
|
if ((b.version_number ?? 0) !== (a.version_number ?? 0)) return (b.version_number ?? 0) - (a.version_number ?? 0);
|
||||||
|
return new Date(b.submitted_at || 0).getTime() - new Date(a.submitted_at || 0).getTime();
|
||||||
|
})[0];
|
||||||
|
|
||||||
|
if (deadlineRow) return deadlineRow;
|
||||||
|
|
||||||
|
const currentVersion = getCurrentVersionForTask(task, taskSubs);
|
||||||
|
const currentVersionSubs = taskSubs.filter(submission => (submission.version_number || 0) === currentVersion);
|
||||||
|
|
||||||
|
return [...currentVersionSubs].sort((a, b) => {
|
||||||
|
if ((b.version_number ?? 0) !== (a.version_number ?? 0)) return (b.version_number ?? 0) - (a.version_number ?? 0);
|
||||||
|
return new Date(b.submitted_at || 0).getTime() - new Date(a.submitted_at || 0).getTime();
|
||||||
|
})[0] || null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export async function withTimeout(promise, ms = 12000, label = 'Request') {
|
||||||
|
let timerId;
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
promise,
|
||||||
|
new Promise((_, reject) => {
|
||||||
|
timerId = window.setTimeout(() => reject(new Error(`${label} timed out`)), ms);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
if (timerId) window.clearTimeout(timerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-3
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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}>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
+629
-130
File diff suppressed because it is too large
Load Diff
Executable → Regular
+1
-1
@@ -1 +1 @@
|
|||||||
v2.78.1
|
v2.98.2
|
||||||
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
+1
-1
@@ -1 +1 @@
|
|||||||
fix-optimized-search-function
|
operation-ergonomics
|
||||||
Executable → Regular
+1
-1
@@ -1 +1 @@
|
|||||||
v1.37.7
|
v1.48.20
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
+19
-21
@@ -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
Reference in New Issue
Block a user