Session 2026-05-29: profile layout 2-col, file browser copy/paste, fbq-proxy/backfill fns, migrations, UI updates
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@@ -0,0 +1,212 @@
|
|||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
const FB_SOURCE = 'srv';
|
||||||
|
|
||||||
|
function normalizePath(path) {
|
||||||
|
const raw = String(path || '/').trim();
|
||||||
|
const parts = raw.split('/').filter(Boolean);
|
||||||
|
const clean = [];
|
||||||
|
for (const part of parts) {
|
||||||
|
if (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) {
|
||||||
|
return String(value || '')
|
||||||
|
.trim()
|
||||||
|
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function basename(path) {
|
||||||
|
const parts = normalizePath(path).split('/').filter(Boolean);
|
||||||
|
return parts[parts.length - 1] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFbConfig() {
|
||||||
|
const url = String(process.env.FILEBROWSER_URL || '').trim().replace(/\/+$/, '');
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
token: process.env.FILEBROWSER_TOKEN || '',
|
||||||
|
teamRoot: normalizePath(process.env.FILEBROWSER_TEAM_ROOT || '/Team'),
|
||||||
|
clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/Clients'),
|
||||||
|
archiveRoot: normalizePath(process.env.FILEBROWSER_ARCHIVE_ROOT || '/Archive'),
|
||||||
|
configured: Boolean(url) && Boolean(process.env.FILEBROWSER_TOKEN),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) {
|
||||||
|
const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString();
|
||||||
|
const res = await fetch(`${config.url}${endpoint}?${qs}`, {
|
||||||
|
method,
|
||||||
|
headers: { Authorization: `Bearer ${config.token}`, ...headers },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
const err = new Error(text || `FileBrowser ${res.status}`);
|
||||||
|
err.status = res.status;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const text = await res.text();
|
||||||
|
try { return text ? JSON.parse(text) : null; } catch { return text; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fbExists(config, path) {
|
||||||
|
try { await fbFetch(config, 'GET', '/api/resources', { params: { path } }); return true; }
|
||||||
|
catch (e) { if (e.status === 404) return false; throw e; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fbMkdir(config, path) {
|
||||||
|
await fbFetch(config, 'POST', '/api/resources', { params: { path, isDir: 'true' } }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listFolders(config, path) {
|
||||||
|
try {
|
||||||
|
const data = await fbFetch(config, 'GET', '/api/resources', { params: { path } });
|
||||||
|
return (data?.folders || []).map(f => f.name).filter(Boolean);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.status === 404) return [];
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mergeMove(config, fbSrc, fbDstParent) {
|
||||||
|
const name = basename(fbSrc);
|
||||||
|
const fbDst = joinPath(fbDstParent, name);
|
||||||
|
const destExists = await fbExists(config, fbDst);
|
||||||
|
if (!destExists) {
|
||||||
|
await fbFetch(config, 'PATCH', '/api/resources', {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: 'move',
|
||||||
|
items: [{ fromSource: FB_SOURCE, fromPath: fbSrc, toSource: FB_SOURCE, toPath: fbDst }],
|
||||||
|
overwrite: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await fbFetch(config, 'GET', '/api/resources', { params: { path: fbSrc } });
|
||||||
|
const dirs = (data?.folders || []).map(f => f.name);
|
||||||
|
const files = (data?.files || []).map(f => f.name);
|
||||||
|
await fbMkdir(config, fbDst);
|
||||||
|
for (const dir of dirs) await mergeMove(config, joinPath(fbSrc, dir), fbDst);
|
||||||
|
for (const file of files) {
|
||||||
|
await fbFetch(config, 'PATCH', '/api/resources', {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: 'move',
|
||||||
|
items: [{ fromSource: FB_SOURCE, fromPath: joinPath(fbSrc, file), toSource: FB_SOURCE, toPath: joinPath(fbDst, file) }],
|
||||||
|
overwrite: true,
|
||||||
|
}),
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
await fbFetch(config, 'DELETE', '/api/resources', { params: { path: fbSrc } }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function json(res, status, body) {
|
||||||
|
return res.status(status).json(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function handler(req, res) {
|
||||||
|
if (req.method !== 'POST') return json(res, 405, { error: 'Method not allowed' });
|
||||||
|
|
||||||
|
const secret = process.env.SUPABASE_WEBHOOK_SECRET;
|
||||||
|
if (secret) {
|
||||||
|
const incoming = req.headers['x-webhook-secret'] || '';
|
||||||
|
if (incoming.trim() !== secret.trim()) return json(res, 401, { error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabaseUrl = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL;
|
||||||
|
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||||
|
if (!supabaseUrl || !serviceRoleKey) return json(res, 500, { error: 'Supabase env not configured' });
|
||||||
|
|
||||||
|
const admin = createClient(supabaseUrl, serviceRoleKey, {
|
||||||
|
auth: { persistSession: false, autoRefreshToken: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = getFbConfig();
|
||||||
|
if (!config.configured) return json(res, 200, { ok: true, skipped: 'FileBrowser not configured' });
|
||||||
|
|
||||||
|
const results = {
|
||||||
|
archivedTeamFolders: [],
|
||||||
|
archivedProjectFolders: [],
|
||||||
|
errors: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
await fbMkdir(config, config.archiveRoot);
|
||||||
|
await fbMkdir(config, joinPath(config.archiveRoot, 'Team'));
|
||||||
|
await fbMkdir(config, joinPath(config.archiveRoot, 'Clients'));
|
||||||
|
|
||||||
|
const { data: profiles, error: profilesError } = await admin
|
||||||
|
.from('profiles')
|
||||||
|
.select('name, role')
|
||||||
|
.in('role', ['team', 'external']);
|
||||||
|
if (profilesError) return json(res, 500, { error: profilesError.message });
|
||||||
|
|
||||||
|
const validTeamFolders = new Set((profiles || []).map(profile => safeName(profile.name)).filter(Boolean));
|
||||||
|
const teamFolders = await listFolders(config, config.teamRoot);
|
||||||
|
for (const folder of teamFolders) {
|
||||||
|
if (validTeamFolders.has(folder)) continue;
|
||||||
|
try {
|
||||||
|
await mergeMove(config, joinPath(config.teamRoot, folder), joinPath(config.archiveRoot, 'Team'));
|
||||||
|
results.archivedTeamFolders.push(folder);
|
||||||
|
} catch (e) {
|
||||||
|
results.errors.push(`team:${folder}: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: companies, error: companiesError } = await admin
|
||||||
|
.from('companies')
|
||||||
|
.select('id, name');
|
||||||
|
if (companiesError) return json(res, 500, { error: companiesError.message });
|
||||||
|
|
||||||
|
const { data: projects, error: projectsError } = await admin
|
||||||
|
.from('projects')
|
||||||
|
.select('name, company_id');
|
||||||
|
if (projectsError) return json(res, 500, { error: projectsError.message });
|
||||||
|
|
||||||
|
const projectsByCompany = new Map();
|
||||||
|
for (const project of projects || []) {
|
||||||
|
if (!project?.company_id || !project?.name) continue;
|
||||||
|
const key = project.company_id;
|
||||||
|
if (!projectsByCompany.has(key)) projectsByCompany.set(key, new Set());
|
||||||
|
projectsByCompany.get(key).add(safeName(project.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const company of companies || []) {
|
||||||
|
const companyFolder = safeName(company.name);
|
||||||
|
if (!companyFolder) continue;
|
||||||
|
const validProjects = projectsByCompany.get(company.id) || new Set();
|
||||||
|
const projectsRoot = joinPath(config.clientRoot, companyFolder, 'Projects');
|
||||||
|
const projectFolders = await listFolders(config, projectsRoot);
|
||||||
|
|
||||||
|
await fbMkdir(config, joinPath(config.archiveRoot, 'Clients', companyFolder));
|
||||||
|
await fbMkdir(config, joinPath(config.archiveRoot, 'Clients', companyFolder, 'Projects'));
|
||||||
|
|
||||||
|
for (const folder of projectFolders) {
|
||||||
|
if (validProjects.has(folder)) continue;
|
||||||
|
try {
|
||||||
|
await mergeMove(
|
||||||
|
config,
|
||||||
|
joinPath(projectsRoot, folder),
|
||||||
|
joinPath(config.archiveRoot, 'Clients', companyFolder, 'Projects'),
|
||||||
|
);
|
||||||
|
results.archivedProjectFolders.push(`${companyFolder}/${folder}`);
|
||||||
|
} catch (e) {
|
||||||
|
results.errors.push(`project:${companyFolder}/${folder}: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return json(res, 200, { ok: true, ...results });
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createClient } from '@supabase/supabase-js';
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
const FB_SOURCE = 'files';
|
const FB_SOURCE = 'srv';
|
||||||
|
|
||||||
function normalizePath(path) {
|
function normalizePath(path) {
|
||||||
const raw = String(path || '/').trim();
|
const raw = String(path || '/').trim();
|
||||||
@@ -31,7 +31,7 @@ function getConfig() {
|
|||||||
return {
|
return {
|
||||||
url,
|
url,
|
||||||
token: process.env.FILEBROWSER_TOKEN || '',
|
token: process.env.FILEBROWSER_TOKEN || '',
|
||||||
clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients'),
|
clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/Clients'),
|
||||||
configured: Boolean(url),
|
configured: Boolean(url),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -56,6 +56,37 @@ async function mkdir(config, path) {
|
|||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fbExists(config, path) {
|
||||||
|
try {
|
||||||
|
await fbFetch(config, 'GET', '/api/resources', { params: { path } });
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
if (e.status === 404) return false;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureTaskStructure(config, companyName, projectName, taskTitle) {
|
||||||
|
const companyDir = joinPath(config.clientRoot, safeName(companyName));
|
||||||
|
const projectsDir = joinPath(companyDir, 'Projects');
|
||||||
|
const projectDir = joinPath(projectsDir, safeName(projectName));
|
||||||
|
const taskDir = joinPath(projectDir, safeName(taskTitle));
|
||||||
|
const requestInfoDir = joinPath(taskDir, 'Request Info');
|
||||||
|
const workingFilesDir = joinPath(taskDir, 'Working Files');
|
||||||
|
const oldBooksDir = joinPath(taskDir, 'Old Books');
|
||||||
|
|
||||||
|
await mkdir(config, companyDir);
|
||||||
|
await mkdir(config, projectsDir);
|
||||||
|
await mkdir(config, projectDir);
|
||||||
|
await mkdir(config, joinPath(projectDir, '00 Project Files'));
|
||||||
|
await mkdir(config, taskDir);
|
||||||
|
await mkdir(config, requestInfoDir);
|
||||||
|
await mkdir(config, workingFilesDir);
|
||||||
|
await mkdir(config, oldBooksDir);
|
||||||
|
|
||||||
|
return { companyDir, projectDir, taskDir, requestInfoDir };
|
||||||
|
}
|
||||||
|
|
||||||
function json(res, status, body) {
|
function json(res, status, body) {
|
||||||
return res.status(status).json(body);
|
return res.status(status).json(body);
|
||||||
}
|
}
|
||||||
@@ -80,6 +111,33 @@ export default async function handler(req, res) {
|
|||||||
const config = getConfig();
|
const config = getConfig();
|
||||||
if (!config.configured || !config.token) return json(res, 200, { ok: true, skipped: 'FileBrowser not configured' });
|
if (!config.configured || !config.token) return json(res, 200, { ok: true, skipped: 'FileBrowser not configured' });
|
||||||
|
|
||||||
|
const { data: tasks, error: taskError } = await admin
|
||||||
|
.from('tasks')
|
||||||
|
.select(`
|
||||||
|
id, title,
|
||||||
|
project:projects!inner(
|
||||||
|
id, name,
|
||||||
|
company:companies!inner(name)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (taskError) return json(res, 500, { error: taskError.message });
|
||||||
|
|
||||||
|
const results = {
|
||||||
|
ensuredTasks: 0,
|
||||||
|
processed: 0,
|
||||||
|
skipped: 0,
|
||||||
|
errors: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const task of tasks || []) {
|
||||||
|
const project = task?.project;
|
||||||
|
const company = project?.company;
|
||||||
|
if (!task?.title || !project?.name || !company?.name) continue;
|
||||||
|
await ensureTaskStructure(config, company.name, project.name, task.title);
|
||||||
|
results.ensuredTasks++;
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch all submission files with task/project/company context
|
// Fetch all submission files with task/project/company context
|
||||||
const { data: rows, error } = await admin
|
const { data: rows, error } = await admin
|
||||||
.from('submission_files')
|
.from('submission_files')
|
||||||
@@ -120,52 +178,37 @@ export default async function handler(req, res) {
|
|||||||
}
|
}
|
||||||
groups.get(key).files.push({ name: row.name, storage_path: row.storage_path });
|
groups.get(key).files.push({ name: row.name, storage_path: row.storage_path });
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = { processed: 0, skipped: 0, errors: [] };
|
|
||||||
|
|
||||||
for (const group of groups.values()) {
|
for (const group of groups.values()) {
|
||||||
if (group.files.length === 0) { results.skipped++; continue; }
|
if (group.files.length === 0) { results.skipped++; continue; }
|
||||||
|
|
||||||
const revFolder = `R${String(group.versionNumber).padStart(2, '0')}`;
|
const revFolder = `R${String(group.versionNumber).padStart(2, '0')}`;
|
||||||
const companyDir = joinPath(config.clientRoot, safeName(group.companyName));
|
const { requestInfoDir } = await ensureTaskStructure(config, group.companyName, group.projectName, group.taskTitle);
|
||||||
const projectDir = joinPath(companyDir, 'Projects', safeName(group.projectName));
|
|
||||||
const taskDir = joinPath(projectDir, safeName(group.taskTitle));
|
|
||||||
const requestInfoDir = joinPath(taskDir, 'Request Info');
|
|
||||||
const revDir = joinPath(requestInfoDir, revFolder);
|
const revDir = joinPath(requestInfoDir, revFolder);
|
||||||
|
|
||||||
// Ensure all parent dirs exist
|
|
||||||
await mkdir(config, companyDir);
|
|
||||||
await mkdir(config, joinPath(companyDir, 'Projects'));
|
|
||||||
await mkdir(config, projectDir);
|
|
||||||
await mkdir(config, taskDir);
|
|
||||||
await mkdir(config, requestInfoDir);
|
|
||||||
await mkdir(config, revDir);
|
await mkdir(config, revDir);
|
||||||
|
|
||||||
for (const file of group.files) {
|
for (const file of group.files) {
|
||||||
try {
|
try {
|
||||||
// Get signed URL from Supabase Storage
|
const { data: blob, error: downloadError } = await admin.storage
|
||||||
const { data: signed, error: signedError } = await admin.storage
|
|
||||||
.from('submissions')
|
.from('submissions')
|
||||||
.createSignedUrl(file.storage_path, 60);
|
.download(file.storage_path);
|
||||||
if (signedError || !signed?.signedUrl) {
|
if (downloadError || !blob) {
|
||||||
results.errors.push(`signed url failed: ${file.storage_path}`);
|
results.errors.push(`download failed: ${file.storage_path}`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const fileBuffer = await blob.arrayBuffer();
|
||||||
// Download file from Supabase Storage
|
const form = new FormData();
|
||||||
const fileRes = await fetch(signed.signedUrl);
|
form.append('file', new Blob([fileBuffer]), file.name);
|
||||||
if (!fileRes.ok) {
|
|
||||||
results.errors.push(`download failed: ${file.name}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const fileBuffer = await fileRes.arrayBuffer();
|
|
||||||
|
|
||||||
// Upload to FileBrowser
|
// Upload to FileBrowser
|
||||||
const fbFilePath = joinPath(revDir, file.name);
|
const fbFilePath = joinPath(revDir, file.name);
|
||||||
|
if (await fbExists(config, fbFilePath)) {
|
||||||
|
results.skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
await fbFetch(config, 'POST', '/api/resources', {
|
await fbFetch(config, 'POST', '/api/resources', {
|
||||||
params: { path: fbFilePath, override: 'true' },
|
params: { path: fbFilePath },
|
||||||
headers: { 'Content-Type': 'application/octet-stream' },
|
body: form,
|
||||||
body: fileBuffer,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
results.processed++;
|
results.processed++;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createClient } from '@supabase/supabase-js';
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
const FB_SOURCE = 'files';
|
const FB_SOURCE = 'srv';
|
||||||
|
|
||||||
function normalizePath(path) {
|
function normalizePath(path) {
|
||||||
const raw = String(path || '/').trim();
|
const raw = String(path || '/').trim();
|
||||||
@@ -42,8 +42,8 @@ function getFbConfig() {
|
|||||||
return {
|
return {
|
||||||
url,
|
url,
|
||||||
token: process.env.FILEBROWSER_TOKEN || '',
|
token: process.env.FILEBROWSER_TOKEN || '',
|
||||||
clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients'),
|
clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/Clients'),
|
||||||
archiveRoot: normalizePath(process.env.FILEBROWSER_ARCHIVE_ROOT || '/fourgebranding/Archive'),
|
archiveRoot: normalizePath(process.env.FILEBROWSER_ARCHIVE_ROOT || '/Archive'),
|
||||||
configured: Boolean(url) && Boolean(process.env.FILEBROWSER_TOKEN),
|
configured: Boolean(url) && Boolean(process.env.FILEBROWSER_TOKEN),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createClient } from '@supabase/supabase-js';
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
const FB_SOURCE = 'files';
|
const FB_SOURCE = 'srv';
|
||||||
|
|
||||||
function normalizePath(path) {
|
function normalizePath(path) {
|
||||||
const raw = String(path || '/').trim();
|
const raw = String(path || '/').trim();
|
||||||
@@ -36,8 +36,8 @@ function getFbConfig() {
|
|||||||
return {
|
return {
|
||||||
url,
|
url,
|
||||||
token: process.env.FILEBROWSER_TOKEN || '',
|
token: process.env.FILEBROWSER_TOKEN || '',
|
||||||
clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients'),
|
clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/Clients'),
|
||||||
archiveRoot: normalizePath(process.env.FILEBROWSER_ARCHIVE_ROOT || '/fourgebranding/Archive'),
|
archiveRoot: normalizePath(process.env.FILEBROWSER_ARCHIVE_ROOT || '/Archive'),
|
||||||
configured: Boolean(url) && Boolean(process.env.FILEBROWSER_TOKEN),
|
configured: Boolean(url) && Boolean(process.env.FILEBROWSER_TOKEN),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const FB_SOURCE = 'files';
|
const FB_SOURCE = 'srv';
|
||||||
|
|
||||||
function normalizePath(path) {
|
function normalizePath(path) {
|
||||||
const raw = String(path || '/').trim();
|
const raw = String(path || '/').trim();
|
||||||
@@ -35,7 +35,7 @@ function getConfig() {
|
|||||||
return {
|
return {
|
||||||
url,
|
url,
|
||||||
token: process.env.FILEBROWSER_TOKEN || '',
|
token: process.env.FILEBROWSER_TOKEN || '',
|
||||||
clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients'),
|
clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/Clients'),
|
||||||
configured: Boolean(url),
|
configured: Boolean(url),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const FB_SOURCE = 'files';
|
const FB_SOURCE = 'srv';
|
||||||
const MEMBER_ROLES = new Set(['team', 'external']);
|
const MEMBER_ROLES = new Set(['team', 'external']);
|
||||||
|
|
||||||
function normalizePath(path) {
|
function normalizePath(path) {
|
||||||
@@ -36,7 +36,7 @@ function getConfig() {
|
|||||||
return {
|
return {
|
||||||
url,
|
url,
|
||||||
token: process.env.FILEBROWSER_TOKEN || '',
|
token: process.env.FILEBROWSER_TOKEN || '',
|
||||||
membersRoot: normalizePath(process.env.FILEBROWSER_MEMBERS_ROOT || '/fourgebranding/Team'),
|
membersRoot: normalizePath(process.env.FILEBROWSER_MEMBERS_ROOT || '/Team'),
|
||||||
configured: Boolean(url),
|
configured: Boolean(url),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createClient } from '@supabase/supabase-js';
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
const FB_SOURCE = 'files';
|
const FB_SOURCE = 'srv';
|
||||||
|
|
||||||
function normalizePath(path) {
|
function normalizePath(path) {
|
||||||
const raw = String(path || '/').trim();
|
const raw = String(path || '/').trim();
|
||||||
@@ -31,7 +31,7 @@ function getConfig() {
|
|||||||
return {
|
return {
|
||||||
url,
|
url,
|
||||||
token: process.env.FILEBROWSER_TOKEN || '',
|
token: process.env.FILEBROWSER_TOKEN || '',
|
||||||
clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients'),
|
clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/Clients'),
|
||||||
configured: Boolean(url),
|
configured: Boolean(url),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const FB_SOURCE = 'files';
|
const FB_SOURCE = 'srv';
|
||||||
|
|
||||||
function normalizePath(path) {
|
function normalizePath(path) {
|
||||||
const raw = String(path || '/').trim();
|
const raw = String(path || '/').trim();
|
||||||
@@ -29,7 +29,7 @@ function getConfig() {
|
|||||||
return {
|
return {
|
||||||
url,
|
url,
|
||||||
token: process.env.FILEBROWSER_TOKEN || '',
|
token: process.env.FILEBROWSER_TOKEN || '',
|
||||||
clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients'),
|
clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/Clients'),
|
||||||
configured: Boolean(url),
|
configured: Boolean(url),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -100,6 +100,7 @@ export default async function handler(req, res) {
|
|||||||
await mkdir(config, taskDir);
|
await mkdir(config, taskDir);
|
||||||
await mkdir(config, joinPath(taskDir, 'Working Files'));
|
await mkdir(config, joinPath(taskDir, 'Working Files'));
|
||||||
await mkdir(config, joinPath(taskDir, 'Request Info'));
|
await mkdir(config, joinPath(taskDir, 'Request Info'));
|
||||||
|
await mkdir(config, joinPath(taskDir, 'Old Books'));
|
||||||
|
|
||||||
res.status(200).json({ ok: true, type, company: record.company_name, project: record.project_name, task: record.title });
|
res.status(200).json({ ok: true, type, company: record.company_name, project: record.project_name, task: record.title });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ This is the single source of truth for dashboard/profile visual structure and UI
|
|||||||
- Accent: `#F5A523`
|
- Accent: `#F5A523`
|
||||||
- Card bg dark: `rgba(255,255,255,0.02)`
|
- Card bg dark: `rgba(255,255,255,0.02)`
|
||||||
- Card bg light: `rgba(0,0,0,0.02)`
|
- Card bg light: `rgba(0,0,0,0.02)`
|
||||||
|
- Sidebar/menu shell uses the same transparent card background token as cards
|
||||||
- Secondary card tone dark: `rgba(255,255,255,0.08)`
|
- Secondary card tone dark: `rgba(255,255,255,0.08)`
|
||||||
- Secondary card tone light: `rgba(0,0,0,0.08)`
|
- Secondary card tone light: `rgba(0,0,0,0.08)`
|
||||||
- Border dark: `rgba(245,165,35,0.15)`
|
- Border dark: `rgba(245,165,35,0.15)`
|
||||||
@@ -44,6 +45,9 @@ This is the single source of truth for dashboard/profile visual structure and UI
|
|||||||
- `border-radius: 8px`
|
- `border-radius: 8px`
|
||||||
- `padding: 18px 21px`
|
- `padding: 18px 21px`
|
||||||
- `backdrop-filter: blur(12px)` + `-webkit-backdrop-filter`
|
- `backdrop-filter: blur(12px)` + `-webkit-backdrop-filter`
|
||||||
|
- Card width is not an intrinsic card token.
|
||||||
|
- Width is owned by the page/grid/container the card lives in.
|
||||||
|
- Cards should fill the slot provided by that page layout unless a page-level rule explicitly fixes a track.
|
||||||
- Compact card radius (legacy generic `.card`): `4px` (do not use for new dashboard widgets)
|
- Compact card radius (legacy generic `.card`): `4px` (do not use for new dashboard widgets)
|
||||||
|
|
||||||
## 6) Header + Top Right Controls
|
## 6) Header + Top Right Controls
|
||||||
@@ -57,7 +61,8 @@ This is the single source of truth for dashboard/profile visual structure and UI
|
|||||||
|
|
||||||
## 7) Dashboard Grids (Team)
|
## 7) Dashboard Grids (Team)
|
||||||
- Stat row: `grid-template-columns: 1fr 1fr 1fr 1.5fr`, `gap: 24`, `margin-bottom: 0`
|
- Stat row: `grid-template-columns: 1fr 1fr 1fr 1.5fr`, `gap: 24`, `margin-bottom: 0`
|
||||||
- Row 2: `grid-template-columns: 1fr 280px`, `gap: 24`, `margin-top: 24`
|
- Row 2: `grid-template-columns: 1fr 1fr 280px`, `gap: 24`, `margin-top: 24`
|
||||||
|
- Order: Recent Activity (left), Tasks In Progress (center), Calendar (right, fixed width)
|
||||||
- Row 3: `grid-template-columns: 1fr 1fr`, `gap: 24`, `margin-top: 24`
|
- Row 3: `grid-template-columns: 1fr 1fr`, `gap: 24`, `margin-top: 24`
|
||||||
- Row 4 full-width: `margin-top: 24`
|
- Row 4 full-width: `margin-top: 24`
|
||||||
|
|
||||||
@@ -96,6 +101,9 @@ This is the single source of truth for dashboard/profile visual structure and UI
|
|||||||
|
|
||||||
## 11) Tables
|
## 11) Tables
|
||||||
- General table layout in dashboard cards: `table-layout: fixed`, `border-collapse: collapse`
|
- General table layout in dashboard cards: `table-layout: fixed`, `border-collapse: collapse`
|
||||||
|
- Column widths are table-specific, not universal tokens.
|
||||||
|
- Widths belong to the individual table/view that defines them.
|
||||||
|
- Reuse widths only when the same table pattern is intentionally repeated.
|
||||||
- Header cells:
|
- Header cells:
|
||||||
- `font-size: 10px`, `font-weight: 500`, uppercase, `letter-spacing: 0.6px`
|
- `font-size: 10px`, `font-weight: 500`, uppercase, `letter-spacing: 0.6px`
|
||||||
- bottom spacing: `padding-bottom: 12px`
|
- bottom spacing: `padding-bottom: 12px`
|
||||||
@@ -103,7 +111,7 @@ This is the single source of truth for dashboard/profile visual structure and UI
|
|||||||
- primary text: `13px`
|
- primary text: `13px`
|
||||||
- secondary/metrics text: `12px`
|
- secondary/metrics text: `12px`
|
||||||
- row vertical spacing via cell padding: typically `5px`
|
- row vertical spacing via cell padding: typically `5px`
|
||||||
- Hot Items column widths:
|
- Hot Tasks column widths:
|
||||||
- check `10%`, task `40%`, requested by `35%`, due by `15%`
|
- check `10%`, task `40%`, requested by `35%`, due by `15%`
|
||||||
- Client Highlight column widths:
|
- Client Highlight column widths:
|
||||||
- icon `5%`, company `22%`, contact `23%`, projects `13%`, open `13%`, outstanding `12%`, paid `12%`
|
- icon `5%`, company `22%`, contact `23%`, projects `13%`, open `13%`, outstanding `12%`, paid `12%`
|
||||||
@@ -114,26 +122,105 @@ This is the single source of truth for dashboard/profile visual structure and UI
|
|||||||
|
|
||||||
## 12) Profile Page
|
## 12) Profile Page
|
||||||
- Container: full available content width, column, `gap: 24`
|
- Container: full available content width, column, `gap: 24`
|
||||||
- Top row: `grid-template-columns: 1fr 280px`, `gap: 24`
|
- Top row: `grid-template-columns: 60fr 40fr`, `gap: 24`
|
||||||
- At `<=1200px`: top row stacks to one column
|
- At `<=1200px`: top row stacks to one column
|
||||||
- Main profile card uses widget shell
|
- Main profile card uses widget shell
|
||||||
|
- Profile card width is determined by the profile page grid, not by the card itself.
|
||||||
- Internal card layout:
|
- Internal card layout:
|
||||||
- row `gap: 20px`
|
- row `gap: 20px`
|
||||||
- portrait column `width: 160px`, portrait max `140x140`, circle
|
- portrait max `140x140`, circle
|
||||||
|
- portrait aligns flush in the row without extra wrapper padding/side space
|
||||||
- detail grid `140px 1fr`, `row-gap: 8`, `column-gap: 12`, `margin-top: 14`
|
- detail grid `140px 1fr`, `row-gap: 8`, `column-gap: 12`, `margin-top: 14`
|
||||||
|
- profile name/title: `18px`, `500`, `line-height: 1.2`
|
||||||
|
- company subtitle: `13px`, secondary text
|
||||||
|
- contact/detail rows: `13px`, primary text
|
||||||
- social row `margin-top: 14`, `gap: 8`
|
- social row `margin-top: 14`, `gap: 8`
|
||||||
- self-only edit button: `position: absolute`, `top: 18px`, `right: 21px` (aligns to card padding), `border-radius: 8px` (matches card), `height: 30px`, `font-size: 12px`
|
- company line under title is always visible; fallback display is `—` when no company is assigned
|
||||||
|
- right-side meta labels (`Member Since`, `Role`) use widget-title sizing: `11px`, `500`, uppercase, `letter-spacing: 0.8px`
|
||||||
|
- right-side meta values use body sizing: `13px`
|
||||||
|
- self-only edit button: `position: absolute`, `top: 18px`, `right: 21px` (aligns to card padding), `border-radius: 8px` (matches card), `height: 30px`, `font-size: 12px`, `padding: 0 12px`
|
||||||
|
- default outline button hover/focus fills with accent gold and uses dark text
|
||||||
- Right calendar card shows only tasks/events assigned to the viewed profile user
|
- Right calendar card shows only tasks/events assigned to the viewed profile user
|
||||||
- Modal:
|
- Profile page left column includes a `Tasks In Progress` card above `Recent Activity`
|
||||||
- overlay: fixed inset, `z-index: 1200`, bg `rgba(0,0,0,0.58)`, blur `6px`
|
- Profile `Tasks In Progress` follows the dashboard table/card pattern:
|
||||||
- overlay padding: `24px`
|
- same widget shell and header treatment
|
||||||
- modal width: `min(620px, 100%)`
|
- same show-all behavior (5 visible by default)
|
||||||
- modal max-height: `calc(100vh - 48px)`, `overflow-y: auto`
|
- profile-specific filter is tasks assigned to the viewed user with statuses `in_progress`, `on_hold`, or `client_review`
|
||||||
|
- columns are `Task` and `Status`
|
||||||
|
- Profile activity feed action text pattern: `Task started`, `Task submitted`, `Task approved`, `Task rejected` (sentence title case)
|
||||||
|
- Activity feed includes:
|
||||||
|
- actions performed by the viewed user
|
||||||
|
- `Task approved` and `Task rejected` entries for tasks assigned to that viewed user (even if actioned by someone else)
|
||||||
|
## 12.5) File Sharing
|
||||||
|
- Main split: `grid-template-columns: minmax(0, 1fr) minmax(0, 3fr)` for an effective `25 / 75` layout, `gap: 24`
|
||||||
|
- Left stacked cards (`Pinned`, `Navigation`): `gap: 24`
|
||||||
|
- Canonical folder structure:
|
||||||
|
- team and subcontractor profile folders live at `Team/<User Name>`
|
||||||
|
- company folders live at `Clients/<Company Name>`
|
||||||
|
- project folders live at `Clients/<Company Name>/Projects/<Project Name>`
|
||||||
|
- every project auto-creates `00 Project Files`
|
||||||
|
- task folders live at `Clients/<Company Name>/Projects/<Project Name>/<Task Name>`
|
||||||
|
- every task auto-creates `Request Info`, `Working Files`, and `Old Books`
|
||||||
|
- request submission files are copied into `Request Info/R00`, `R01`, and higher revision folders
|
||||||
|
- renaming a team user, subcontractor user, company, project, or task must rename the matching filesystem folder
|
||||||
|
- folder repair/backfill is additive only: create missing folders, leave existing folders untouched
|
||||||
|
- request-info backfill must skip files that already exist and must not overwrite added working files or user-managed files
|
||||||
|
- deleting a task in the portal moves its task folder into `Archive/Clients/<Company Name>/Projects/<Project Name>/<Task Name>` instead of deleting it
|
||||||
|
- deleting a project in the portal moves its project folder into `Archive/Clients/<Company Name>/Projects/<Project Name>` instead of deleting it
|
||||||
|
- orphan reconciliation may move folders into archive when they no longer map to portal records
|
||||||
|
- orphan team folders move to `Archive/Team/<Folder Name>` when no team or subcontractor profile matches that folder name
|
||||||
|
- orphan project folders move to `Archive/Clients/<Company Name>/Projects/<Project Name>` when no portal project matches that folder name under that company
|
||||||
|
- orphan reconciliation does not remove company roots or overwrite archived content; it merge-moves into archive
|
||||||
|
- Access/home rules:
|
||||||
|
- team members have full file access
|
||||||
|
- client users with exactly one tied company use that company folder as home: `/Clients/<Company Name>`
|
||||||
|
- client users tied to multiple companies use `/Clients` as home and only see company folders they are tied to
|
||||||
|
- `/Clients` acts as a virtual parent for multi-company client users and must not expose unrelated company folders
|
||||||
|
- subcontractor users use virtual home `/`
|
||||||
|
- subcontractor virtual home shows `Team` plus only the project folders they are assigned to
|
||||||
|
- subcontractor users skip all other root folders and must not see unrelated client or internal roots
|
||||||
|
- subcontractor `Team` maps to the real shared `Team` folder
|
||||||
|
- each subcontractor project folder is a virtual root that maps to the real client project path `/Clients/<Company>/Projects/<Project>`
|
||||||
|
- Right card header:
|
||||||
|
- title label is `File Browser`
|
||||||
|
- drag/drop note sits directly under the title and uses subtext sizing: `12px`
|
||||||
|
- current file path uses the same `11px` uppercase label treatment as the title row family
|
||||||
|
- toolbar does not show `+ Folder` or `Upload` buttons
|
||||||
|
- upload happens by dragging and dropping files or folders into the right card
|
||||||
|
- delete action uses the same geometry as the profile edit button (`top: 18px`, `right: 21px`, `8px` radius, `30px` height, `12px` text, `0 12px` padding) but uses danger styling with red hover/fill
|
||||||
|
- Right card table:
|
||||||
|
- uses dashboard table styling (`table-layout: fixed`, transparent header row, `10px` uppercase headers, `13px/12px` body text)
|
||||||
|
- card padding matches dashboard widget shell (`18px 21px`)
|
||||||
|
- clickable text inside the table highlights in accent; rows do not use block hover fill
|
||||||
|
- body cells are borderless like dashboard card tables
|
||||||
|
- body cell padding matches dashboard tables exactly: `5px`
|
||||||
|
- visible headers (`Name`, `Size`, `Modified`) use shared sortable header controls
|
||||||
|
- file/folder icon footprint is compact so row height stays on the same cadence as dashboard tables
|
||||||
|
- alternating rows use a very subtle theme-aware tint for scanability
|
||||||
|
- Navigation tree:
|
||||||
|
- `Home` aligns to the first arrow column
|
||||||
|
- connector line runs through the arrow center column
|
||||||
|
- pinned folders are user-specific and must not be shared across users on the same device/browser session
|
||||||
|
- hover is text-only accent color, no row fill
|
||||||
|
|
||||||
## 13) Radius + Geometry Rules
|
## 13) Radius + Geometry Rules
|
||||||
- Dashboard/profile widgets: `8px` radius
|
- Dashboard/profile widgets: `8px` radius
|
||||||
- Sidebar: `8px` radius
|
- Sidebar: `8px` radius
|
||||||
- Buttons/input/dropdowns mostly `4px` radius
|
- Standard app buttons use the `Edit Profile` geometry:
|
||||||
|
- height: `30px`
|
||||||
|
- horizontal padding: `0 12px`
|
||||||
|
- border-radius: `8px`
|
||||||
|
- font-size: `11px`
|
||||||
|
- font-weight: `500`
|
||||||
|
- letter-spacing: `0.8px`
|
||||||
|
- text-transform: uppercase
|
||||||
|
- line-height: `1`
|
||||||
|
- label color follows theme text roles:
|
||||||
|
- dark mode: primary button labels use dark text on accent fill; outline labels use primary text on transparent surface
|
||||||
|
- light mode: outline labels use light-theme primary text; danger labels use danger text
|
||||||
|
- outline button border uses the same `var(--border)` token as card outlines unless a button role explicitly overrides it
|
||||||
|
- keep existing button role styling intact (`outline`, `primary`, `danger`); geometry changes, not role colors
|
||||||
|
- Inputs/dropdowns may use their own control radius unless a page-specific rule says otherwise
|
||||||
- Circular elements (avatar/day/icon badges): `50%`
|
- Circular elements (avatar/day/icon badges): `50%`
|
||||||
|
|
||||||
## 14) Z-Index Stack
|
## 14) Z-Index Stack
|
||||||
@@ -155,9 +242,13 @@ This is the single source of truth for dashboard/profile visual structure and UI
|
|||||||
- Dark hover surface baseline: `#1f1f1f`.
|
- Dark hover surface baseline: `#1f1f1f`.
|
||||||
- Light hover surface baseline: `rgba(0,0,0,0.08)`.
|
- Light hover surface baseline: `rgba(0,0,0,0.08)`.
|
||||||
- Nav icon opacity must lift from muted to full on hover (`opacity: 1`).
|
- Nav icon opacity must lift from muted to full on hover (`opacity: 1`).
|
||||||
|
- Clickable text links use text-only highlight on hover/focus (`color: var(--accent)`), not whole-row/background fill.
|
||||||
- Hover and active must be visually distinct:
|
- Hover and active must be visually distinct:
|
||||||
- hover uses stronger temporary contrast (`bg` + thin border),
|
- hover uses stronger temporary contrast (`bg` + thin border),
|
||||||
- active remains persistent selected-state background.
|
- active remains persistent selected-state background.
|
||||||
|
- File Sharing left navigation exception:
|
||||||
|
- `Pinned` + `Navigation` tree rows use text-only hover highlight (`color: var(--accent)`).
|
||||||
|
- Do not apply row background fill for hover in this section.
|
||||||
|
|
||||||
## 16) Non-Negotiable Implementation Rules
|
## 16) Non-Negotiable Implementation Rules
|
||||||
- Keep gradient backgrounds on `html`, not `body`
|
- Keep gradient backgrounds on `html`, not `body`
|
||||||
@@ -168,4 +259,10 @@ This is the single source of truth for dashboard/profile visual structure and UI
|
|||||||
2. Active Projects
|
2. Active Projects
|
||||||
3. Net Profit
|
3. Net Profit
|
||||||
4. Revenue (wide)
|
4. Revenue (wide)
|
||||||
- Keep row 2 order: Recent Activity (left), Calendar (right)
|
- Keep row 2 order: Recent Activity (left), Tasks In Progress (center), Calendar (right fixed at `280px`)
|
||||||
|
|
||||||
|
## 18) Invoice Rules
|
||||||
|
- New work (`R00`) is billed at the new-service rate.
|
||||||
|
- Revision `R01` is free.
|
||||||
|
- Revisions `R02+` are billed incrementally: each uninvoiced revision entry contributes exactly `1x` revision charge.
|
||||||
|
- Previously invoiced revisions are excluded; later invoices only charge newly uninvoiced revisions (e.g. if `R02` was already invoiced, at `R04` only `R03` + `R04` are billed).
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
@@ -19,8 +19,8 @@ const SUPABASE_URL = env.VITE_SUPABASE_URL || env.SUPABASE_URL;
|
|||||||
const SERVICE_ROLE_KEY = env.SUPABASE_SERVICE_ROLE_KEY;
|
const SERVICE_ROLE_KEY = env.SUPABASE_SERVICE_ROLE_KEY;
|
||||||
const FB_URL = (env.FILEBROWSER_URL || '').replace(/\/+$/, '');
|
const FB_URL = (env.FILEBROWSER_URL || '').replace(/\/+$/, '');
|
||||||
const FB_TOKEN = env.FILEBROWSER_TOKEN;
|
const FB_TOKEN = env.FILEBROWSER_TOKEN;
|
||||||
const CLIENT_ROOT = env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients';
|
const CLIENT_ROOT = env.FILEBROWSER_CLIENT_ROOT || '/Clients';
|
||||||
const FB_SOURCE = 'files';
|
const FB_SOURCE = 'srv';
|
||||||
|
|
||||||
if (!SUPABASE_URL || !SERVICE_ROLE_KEY) { console.error('Missing Supabase env'); process.exit(1); }
|
if (!SUPABASE_URL || !SERVICE_ROLE_KEY) { console.error('Missing Supabase env'); process.exit(1); }
|
||||||
if (!FB_URL || !FB_TOKEN) { console.error('Missing FileBrowser env'); process.exit(1); }
|
if (!FB_URL || !FB_TOKEN) { console.error('Missing FileBrowser env'); process.exit(1); }
|
||||||
@@ -67,7 +67,59 @@ async function mkdir(path) {
|
|||||||
await fbFetch('POST', '/api/resources', { params: { path, isDir: 'true' } }).catch(() => {});
|
await fbFetch('POST', '/api/resources', { params: { path, isDir: 'true' } }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fbExists(path) {
|
||||||
|
try {
|
||||||
|
await fbFetch('GET', '/api/resources', { params: { path } });
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
if (String(e.message || '').includes('404')) return false;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureTaskStructure(companyName, projectName, taskTitle) {
|
||||||
|
const companyDir = joinPath(CLIENT_ROOT, safeName(companyName));
|
||||||
|
const projectsDir = joinPath(companyDir, 'Projects');
|
||||||
|
const projectDir = joinPath(projectsDir, safeName(projectName));
|
||||||
|
const taskDir = joinPath(projectDir, safeName(taskTitle));
|
||||||
|
const requestInfoDir = joinPath(taskDir, 'Request Info');
|
||||||
|
const workingFilesDir = joinPath(taskDir, 'Working Files');
|
||||||
|
const oldBooksDir = joinPath(taskDir, 'Old Books');
|
||||||
|
|
||||||
|
await mkdir(companyDir);
|
||||||
|
await mkdir(projectsDir);
|
||||||
|
await mkdir(projectDir);
|
||||||
|
await mkdir(joinPath(projectDir, '00 Project Files'));
|
||||||
|
await mkdir(taskDir);
|
||||||
|
await mkdir(requestInfoDir);
|
||||||
|
await mkdir(workingFilesDir);
|
||||||
|
await mkdir(oldBooksDir);
|
||||||
|
|
||||||
|
return { requestInfoDir };
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
const { data: tasks, error: taskError } = await admin
|
||||||
|
.from('tasks')
|
||||||
|
.select(`
|
||||||
|
id, title,
|
||||||
|
project:projects!inner(
|
||||||
|
id, name,
|
||||||
|
company:companies!inner(name)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (taskError) { console.error('Task query failed:', taskError.message); process.exit(1); }
|
||||||
|
|
||||||
|
let ensuredTasks = 0;
|
||||||
|
for (const task of tasks || []) {
|
||||||
|
const project = task?.project;
|
||||||
|
const company = project?.company;
|
||||||
|
if (!task?.title || !project?.name || !company?.name) continue;
|
||||||
|
await ensureTaskStructure(company.name, project.name, task.title);
|
||||||
|
ensuredTasks++;
|
||||||
|
}
|
||||||
|
|
||||||
const { data: rows, error } = await admin
|
const { data: rows, error } = await admin
|
||||||
.from('submission_files')
|
.from('submission_files')
|
||||||
.select(`
|
.select(`
|
||||||
@@ -108,6 +160,7 @@ async function main() {
|
|||||||
groups.get(key).files.push({ name: row.name, storage_path: row.storage_path });
|
groups.get(key).files.push({ name: row.name, storage_path: row.storage_path });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`Ensured structure for ${ensuredTasks} tasks`);
|
||||||
console.log(`Found ${groups.size} task/revision groups, ${rows.length} total files`);
|
console.log(`Found ${groups.size} task/revision groups, ${rows.length} total files`);
|
||||||
|
|
||||||
let processed = 0, skipped = 0, errors = 0;
|
let processed = 0, skipped = 0, errors = 0;
|
||||||
@@ -116,38 +169,32 @@ async function main() {
|
|||||||
if (group.files.length === 0) { skipped++; continue; }
|
if (group.files.length === 0) { skipped++; continue; }
|
||||||
|
|
||||||
const revFolder = `R${String(group.versionNumber).padStart(2, '0')}`;
|
const revFolder = `R${String(group.versionNumber).padStart(2, '0')}`;
|
||||||
const companyDir = joinPath(CLIENT_ROOT, safeName(group.companyName));
|
const { requestInfoDir } = await ensureTaskStructure(group.companyName, group.projectName, group.taskTitle);
|
||||||
const projectDir = joinPath(companyDir, 'Projects', safeName(group.projectName));
|
|
||||||
const taskDir = joinPath(projectDir, safeName(group.taskTitle));
|
|
||||||
const requestInfoDir = joinPath(taskDir, 'Request Info');
|
|
||||||
const revDir = joinPath(requestInfoDir, revFolder);
|
const revDir = joinPath(requestInfoDir, revFolder);
|
||||||
|
|
||||||
console.log(`\n[${group.companyName}] ${group.projectName} / ${group.taskTitle} / ${revFolder} (${group.files.length} files)`);
|
console.log(`\n[${group.companyName}] ${group.projectName} / ${group.taskTitle} / ${revFolder} (${group.files.length} files)`);
|
||||||
|
|
||||||
await mkdir(companyDir);
|
|
||||||
await mkdir(joinPath(companyDir, 'Projects'));
|
|
||||||
await mkdir(projectDir);
|
|
||||||
await mkdir(taskDir);
|
|
||||||
await mkdir(requestInfoDir);
|
|
||||||
await mkdir(revDir);
|
await mkdir(revDir);
|
||||||
|
|
||||||
for (const file of group.files) {
|
for (const file of group.files) {
|
||||||
try {
|
try {
|
||||||
const { data: signed, error: signErr } = await admin.storage
|
const { data: blob, error: downloadErr } = await admin.storage
|
||||||
.from('submissions')
|
.from('submissions')
|
||||||
.createSignedUrl(file.storage_path, 120);
|
.download(file.storage_path);
|
||||||
|
if (downloadErr || !blob) throw new Error(`download failed: ${downloadErr?.message}`);
|
||||||
if (signErr || !signed?.signedUrl) throw new Error(`signed url failed: ${signErr?.message}`);
|
const fileBuffer = await blob.arrayBuffer();
|
||||||
|
const form = new FormData();
|
||||||
const fileRes = await fetch(signed.signedUrl);
|
form.append('file', new Blob([fileBuffer]), file.name);
|
||||||
if (!fileRes.ok) throw new Error(`download failed: ${fileRes.status}`);
|
|
||||||
const fileBuffer = await fileRes.arrayBuffer();
|
|
||||||
|
|
||||||
const fbFilePath = joinPath(revDir, file.name);
|
const fbFilePath = joinPath(revDir, file.name);
|
||||||
|
if (await fbExists(fbFilePath)) {
|
||||||
|
console.log(` - skip existing ${file.name}`);
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
await fbFetch('POST', '/api/resources', {
|
await fbFetch('POST', '/api/resources', {
|
||||||
params: { path: fbFilePath, override: 'true' },
|
params: { path: fbFilePath },
|
||||||
headers: { 'Content-Type': 'application/octet-stream' },
|
body: form,
|
||||||
body: fileBuffer,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(` ✓ ${file.name}`);
|
console.log(` ✓ ${file.name}`);
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 8.5 KiB |
@@ -139,9 +139,12 @@ export default function Layout({ children }) {
|
|||||||
const timeOfDay = hour < 12 ? 'morning' : hour < 17 ? 'afternoon' : 'evening';
|
const timeOfDay = hour < 12 ? 'morning' : hour < 17 ? 'afternoon' : 'evening';
|
||||||
const firstName = currentUser?.name?.split(' ')[0] || '';
|
const firstName = currentUser?.name?.split(' ')[0] || '';
|
||||||
const isProfileRoute = location.pathname === '/profile' || location.pathname.startsWith('/profile/');
|
const isProfileRoute = location.pathname === '/profile' || location.pathname.startsWith('/profile/');
|
||||||
const headerTitle = isProfileRoute ? 'Profile' : `Good ${timeOfDay}${firstName ? `, ${firstName}` : ''}`;
|
const isFileSharingRoute = location.pathname === '/file-sharing';
|
||||||
|
const headerTitle = isProfileRoute ? 'Profile' : isFileSharingRoute ? 'File Sharing' : `Good ${timeOfDay}${firstName ? `, ${firstName}` : ''}`;
|
||||||
const headerSubtitle = isProfileRoute
|
const headerSubtitle = isProfileRoute
|
||||||
? 'Account details and security settings.'
|
? 'Account details and security settings.'
|
||||||
|
: isFileSharingRoute
|
||||||
|
? 'Browse, share and manage files.'
|
||||||
: "Here's what's happening today.";
|
: "Here's what's happening today.";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -277,12 +280,15 @@ export default function Layout({ children }) {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="site-header-avatar-wrap" ref={avatarRef}>
|
<div className="site-header-avatar-wrap" ref={avatarRef}>
|
||||||
<button className="site-header-avatar-btn" onClick={() => setAvatarOpen(o => !o)} aria-label="Account menu">
|
<button className="site-header-avatar-btn" onClick={() => setAvatarOpen(o => !o)} aria-label="Account menu" style={{ overflow: 'hidden' }}>
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4.4 3.6-7 8-7s8 2.6 8 7" fill="currentColor"/></svg>
|
{currentUser?.avatar_url
|
||||||
|
? <img src={currentUser.avatar_url} alt={currentUser.name} style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '50%' }} />
|
||||||
|
: <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4.4 3.6-7 8-7s8 2.6 8 7" fill="currentColor"/></svg>
|
||||||
|
}
|
||||||
</button>
|
</button>
|
||||||
{avatarOpen && (
|
{avatarOpen && (
|
||||||
<div className="site-header-avatar-menu">
|
<div className="site-header-avatar-menu">
|
||||||
<button className="site-header-avatar-item" onClick={() => { navigate('/profile'); setAvatarOpen(false); }}>Settings</button>
|
<button className="site-header-avatar-item" onClick={() => { navigate('/profile'); setAvatarOpen(false); }}>Profile</button>
|
||||||
<div className="site-header-avatar-divider" />
|
<div className="site-header-avatar-divider" />
|
||||||
<button className="site-header-avatar-item" onClick={handleLogout}>Sign Out</button>
|
<button className="site-header-avatar-item" onClick={handleLogout}>Sign Out</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
--border: rgba(245, 165, 35, 0.15);
|
--border: rgba(245, 165, 35, 0.15);
|
||||||
--interactive-hover-border: rgba(245, 165, 35, 0.3);
|
--interactive-hover-border: rgba(245, 165, 35, 0.3);
|
||||||
--interactive-row-hover: rgba(255,255,255,0.03);
|
--interactive-row-hover: rgba(255,255,255,0.03);
|
||||||
|
--file-row-alt-bg: rgba(255,255,255,0.02);
|
||||||
--danger: #ef4444;
|
--danger: #ef4444;
|
||||||
--success: #22c55e;
|
--success: #22c55e;
|
||||||
}
|
}
|
||||||
@@ -56,6 +57,7 @@
|
|||||||
--border: rgba(0,0,0,0.1);
|
--border: rgba(0,0,0,0.1);
|
||||||
--interactive-hover-border: rgba(0,0,0,0.2);
|
--interactive-hover-border: rgba(0,0,0,0.2);
|
||||||
--interactive-row-hover: rgba(0,0,0,0.025);
|
--interactive-row-hover: rgba(0,0,0,0.025);
|
||||||
|
--file-row-alt-bg: rgba(0,0,0,0.02);
|
||||||
}
|
}
|
||||||
[data-theme="light"] input[type="text"],
|
[data-theme="light"] input[type="text"],
|
||||||
[data-theme="light"] input[type="email"],
|
[data-theme="light"] input[type="email"],
|
||||||
@@ -92,8 +94,8 @@
|
|||||||
[data-theme="light"] .badge-fourge_error { background: #f0fdf4; color: #16a34a; border-color: #bbf7d0; }
|
[data-theme="light"] .badge-fourge_error { background: #f0fdf4; color: #16a34a; border-color: #bbf7d0; }
|
||||||
[data-theme="light"] .notification-success { background: #f0fdf4; color: #16a34a; border-color: #bbf7d0; }
|
[data-theme="light"] .notification-success { background: #f0fdf4; color: #16a34a; border-color: #bbf7d0; }
|
||||||
[data-theme="light"] .notification-info { background: #eff6ff; color: #2563eb; border-color: #bfdbfe; }
|
[data-theme="light"] .notification-info { background: #eff6ff; color: #2563eb; border-color: #bfdbfe; }
|
||||||
[data-theme="light"] .btn-outline { color: #1a1a1a; border-color: #d0d0d0; }
|
[data-theme="light"] .btn-outline { color: var(--text-primary); border-color: var(--border); }
|
||||||
[data-theme="light"] .btn-outline:hover { background: #f0f0f0; }
|
[data-theme="light"] .btn-outline:hover { background: var(--accent); border-color: var(--accent); color: #111111; }
|
||||||
[data-theme="light"] .btn-danger { color: var(--danger); border-color: var(--danger); }
|
[data-theme="light"] .btn-danger { color: var(--danger); border-color: var(--danger); }
|
||||||
[data-theme="light"] .btn-danger:hover { background: var(--danger); color: white; }
|
[data-theme="light"] .btn-danger:hover { background: var(--danger); color: white; }
|
||||||
[data-theme="light"] select option { background: #fff; color: #1a1a1a; }
|
[data-theme="light"] select option { background: #fff; color: #1a1a1a; }
|
||||||
@@ -117,7 +119,7 @@ body {
|
|||||||
.sidebar {
|
.sidebar {
|
||||||
width: 76px;
|
width: 76px;
|
||||||
min-width: 76px;
|
min-width: 76px;
|
||||||
background: var(--sidebar-bg);
|
background: var(--card-bg);
|
||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(12px);
|
||||||
-webkit-backdrop-filter: blur(12px);
|
-webkit-backdrop-filter: blur(12px);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -245,7 +247,14 @@ 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: 100px; flex: 1; display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
|
.main-wrapper { margin-left: 100px; flex: 1; display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
|
||||||
.main-content { flex: 1; padding: 24px; overflow-y: scroll; display: block; min-height: 0; scrollbar-gutter: stable; }
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 24px;
|
||||||
|
overflow-y: scroll;
|
||||||
|
display: block;
|
||||||
|
min-height: 0;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
.site-header { display: flex; align-items: flex-start; justify-content: space-between; padding-top: 24px; padding-bottom: 24px; flex-shrink: 0; position: relative; z-index: 50; }
|
.site-header { display: flex; align-items: flex-start; justify-content: space-between; padding-top: 24px; padding-bottom: 24px; flex-shrink: 0; position: relative; z-index: 50; }
|
||||||
.site-header-greeting { font-size: 28px; font-weight: 500; color: var(--text-primary); line-height: 1.2; letter-spacing: -0.3px; }
|
.site-header-greeting { font-size: 28px; font-weight: 500; color: var(--text-primary); line-height: 1.2; letter-spacing: -0.3px; }
|
||||||
.site-header-sub { font-size: 13px; color: var(--text-muted); margin-top: 4px; }
|
.site-header-sub { font-size: 13px; color: var(--text-muted); margin-top: 4px; }
|
||||||
@@ -902,10 +911,10 @@ body {
|
|||||||
/* Buttons */
|
/* Buttons */
|
||||||
.btn {
|
.btn {
|
||||||
display: inline-flex; align-items: center; gap: 6px;
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
height: var(--h-control); padding: 0 14px; border-radius: 4px; font-size: 13px;
|
height: var(--h-control); padding: 0 12px; border-radius: 8px; font-size: 11px;
|
||||||
font-weight: 400; cursor: pointer; border: 1px solid transparent;
|
font-weight: 500; cursor: pointer; border: 1px solid transparent;
|
||||||
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; letter-spacing: 0.8px; text-transform: uppercase;
|
||||||
}
|
}
|
||||||
.btn:disabled {
|
.btn:disabled {
|
||||||
opacity: 0.65;
|
opacity: 0.65;
|
||||||
@@ -929,11 +938,11 @@ body {
|
|||||||
@keyframes btn-spin {
|
@keyframes btn-spin {
|
||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
.btn-sm { padding: 0 12px; font-size: 12px; line-height: 1; }
|
.btn-sm { padding: 0 12px; font-size: 11px; line-height: 1; letter-spacing: 0.8px; text-transform: uppercase; }
|
||||||
.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); }
|
||||||
.btn-outline { background: transparent; color: var(--text-primary); border: 1px solid var(--border); }
|
.btn-outline { background: transparent; color: var(--text-primary); border: 1px solid var(--border); }
|
||||||
.btn-outline:hover { background: var(--card-bg-2); border-color: #444; }
|
.btn-outline:hover { background: var(--accent); border-color: var(--accent); color: #111111; }
|
||||||
.btn-success { background: #22c55e; color: #111; }
|
.btn-success { background: #22c55e; color: #111; }
|
||||||
.btn-success:hover { background: #16a34a; }
|
.btn-success:hover { background: #16a34a; }
|
||||||
.btn-warning { background: var(--accent); color: #111; }
|
.btn-warning { background: var(--accent); color: #111; }
|
||||||
@@ -1080,7 +1089,7 @@ select option { background: #222; color: #fff; }
|
|||||||
|
|
||||||
/* Misc */
|
/* Misc */
|
||||||
.back-link { display: inline-flex; align-items: center; gap: 6px; color: var(--text-muted); text-decoration: none; font-size: 12px; font-weight: 500; margin-bottom: 20px; cursor: pointer; border: none; background: none; text-transform: uppercase; letter-spacing: 0.5px; font-family: inherit; }
|
.back-link { display: inline-flex; align-items: center; gap: 6px; color: var(--text-muted); text-decoration: none; font-size: 12px; font-weight: 500; margin-bottom: 20px; cursor: pointer; border: none; background: none; text-transform: uppercase; letter-spacing: 0.5px; font-family: inherit; }
|
||||||
.back-link:hover { color: var(--text-primary); }
|
.back-link:hover { color: var(--accent); }
|
||||||
.empty-state { text-align: center; padding: 56px 20px; color: var(--text-secondary); }
|
.empty-state { text-align: center; padding: 56px 20px; color: var(--text-secondary); }
|
||||||
.empty-state-icon { display: none; }
|
.empty-state-icon { display: none; }
|
||||||
.empty-state h3 { font-size: 15px; font-weight: 400; color: var(--text-primary); margin-bottom: 6px; }
|
.empty-state h3 { font-size: 15px; font-weight: 400; color: var(--text-primary); margin-bottom: 6px; }
|
||||||
@@ -1468,24 +1477,37 @@ select option { background: #222; color: #fff; }
|
|||||||
.tab-btn.active { background: var(--accent); border-color: var(--accent); color: #000; font-weight: 400; }
|
.tab-btn.active { background: var(--accent); border-color: var(--accent); color: #000; font-weight: 400; }
|
||||||
|
|
||||||
/* Rebuilt hover system (single source of truth) */
|
/* Rebuilt hover system (single source of truth) */
|
||||||
|
.sidebar-link:hover {
|
||||||
|
background-color: #1f1f1f;
|
||||||
|
border-color: rgba(255,255,255,0.08);
|
||||||
|
}
|
||||||
.sidebar-theme-toggle:hover,
|
.sidebar-theme-toggle:hover,
|
||||||
.site-header-dropdown-item:hover,
|
.site-header-dropdown-item:hover {
|
||||||
.site-header-avatar-item:hover {
|
|
||||||
background-color: rgba(255,255,255,0.12);
|
background-color: rgba(255,255,255,0.12);
|
||||||
}
|
}
|
||||||
.site-header-search-btn:hover,
|
.site-header-search-btn:hover,
|
||||||
.site-header-theme-toggle:hover {
|
.site-header-theme-toggle:hover,
|
||||||
|
.site-header-avatar-item:hover {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
[data-theme="dark"] .sidebar-link:hover {
|
||||||
|
background-color: #1f1f1f;
|
||||||
|
border-color: rgba(255,255,255,0.08);
|
||||||
|
}
|
||||||
[data-theme="dark"] .sidebar-theme-toggle:hover,
|
[data-theme="dark"] .sidebar-theme-toggle:hover,
|
||||||
[data-theme="dark"] .site-header-dropdown-item:hover,
|
[data-theme="dark"] .site-header-dropdown-item:hover {
|
||||||
[data-theme="dark"] .site-header-avatar-item:hover {
|
|
||||||
border-color: rgba(255,255,255,0.2);
|
border-color: rgba(255,255,255,0.2);
|
||||||
}
|
}
|
||||||
|
[data-theme="light"] .sidebar-link:hover {
|
||||||
|
background-color: rgba(0,0,0,0.08);
|
||||||
|
border-color: rgba(0,0,0,0.12);
|
||||||
|
}
|
||||||
[data-theme="light"] .sidebar-theme-toggle:hover,
|
[data-theme="light"] .sidebar-theme-toggle:hover,
|
||||||
[data-theme="light"] .site-header-dropdown-item:hover,
|
[data-theme="light"] .site-header-dropdown-item:hover {
|
||||||
[data-theme="light"] .site-header-avatar-item:hover {
|
|
||||||
background-color: rgba(0,0,0,0.14);
|
background-color: rgba(0,0,0,0.14);
|
||||||
border-color: rgba(0,0,0,0.18);
|
border-color: rgba(0,0,0,0.18);
|
||||||
color: #0d0d0d;
|
color: #0d0d0d;
|
||||||
}
|
}
|
||||||
|
[data-theme="light"] .site-header-avatar-item:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,31 @@
|
|||||||
import { supabase } from './supabase';
|
import { supabase } from './supabase';
|
||||||
|
|
||||||
export async function logActivity({ actorId, actorName, action, taskId, taskTitle, projectId, projectName }) {
|
export async function logActivity({ actorId, actorName, action, taskId, taskTitle, projectId, projectName }) {
|
||||||
|
const duplicateWindowMs = 8000;
|
||||||
|
const duplicateCutoff = new Date(Date.now() - duplicateWindowMs).toISOString();
|
||||||
|
const normalizedActorId = actorId || null;
|
||||||
|
const normalizedTaskId = taskId || null;
|
||||||
|
const normalizedProjectId = projectId || null;
|
||||||
|
|
||||||
|
// Guard against fast duplicate writes from double-clicks/retries.
|
||||||
|
const { data: existing, error: existingError } = await supabase
|
||||||
|
.from('activity_log')
|
||||||
|
.select('id')
|
||||||
|
.eq('action', action)
|
||||||
|
.eq('actor_id', normalizedActorId)
|
||||||
|
.eq('task_id', normalizedTaskId)
|
||||||
|
.eq('project_id', normalizedProjectId)
|
||||||
|
.gte('created_at', duplicateCutoff)
|
||||||
|
.limit(1);
|
||||||
|
if (!existingError && (existing || []).length > 0) return;
|
||||||
|
|
||||||
const { error } = await supabase.from('activity_log').insert({
|
const { error } = await supabase.from('activity_log').insert({
|
||||||
actor_id: actorId || null,
|
actor_id: normalizedActorId,
|
||||||
actor_name: actorName || null,
|
actor_name: actorName || null,
|
||||||
action,
|
action,
|
||||||
task_id: taskId || null,
|
task_id: normalizedTaskId,
|
||||||
task_title: taskTitle || null,
|
task_title: taskTitle || null,
|
||||||
project_id: projectId || null,
|
project_id: normalizedProjectId,
|
||||||
project_name: projectName || null,
|
project_name: projectName || null,
|
||||||
});
|
});
|
||||||
if (error) console.error('logActivity failed:', action, error);
|
if (error) console.error('logActivity failed:', action, error);
|
||||||
|
|||||||
@@ -1,33 +1,19 @@
|
|||||||
import { supabase } from './supabase';
|
import { supabase } from './supabase';
|
||||||
|
|
||||||
async function fbCall(method, action, body = null) {
|
const FBQ_FN = `${import.meta.env.VITE_SUPABASE_URL}/functions/v1/fbq-proxy`;
|
||||||
|
|
||||||
|
async function fbq(op, path, extra = {}) {
|
||||||
const { data: { session } } = await supabase.auth.getSession();
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
if (!session?.access_token) return;
|
if (!session?.access_token) return;
|
||||||
|
const headers = {
|
||||||
await fetch(`/api/filebrowser?action=${action}`, {
|
|
||||||
method,
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${session.access_token}`,
|
Authorization: `Bearer ${session.access_token}`,
|
||||||
'Content-Type': 'application/json',
|
'X-Operation': op,
|
||||||
},
|
'X-Path': path,
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
...extra.headers,
|
||||||
}).catch(() => {});
|
};
|
||||||
|
await fetch(FBQ_FN, { method: 'POST', headers, body: extra.body ?? undefined }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create /Clients/{name}/ folder. Silently fails if already exists.
|
|
||||||
export async function createClientFolder(companyName) {
|
|
||||||
if (!companyName) return;
|
|
||||||
await fbCall('POST', 'mkdir', { path: '/', name: 'Clients' });
|
|
||||||
await fbCall('POST', 'mkdir', { path: '/Clients', name: companyName });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rename /Clients/{oldName} → /Clients/{newName}
|
|
||||||
export async function renameClientFolder(oldName, newName) {
|
|
||||||
if (!oldName || !newName || oldName === newName) return;
|
|
||||||
await fbCall('POST', 'rename', { path: `/Clients/${oldName}`, name: newName });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Same safeName logic as the server (api/filebrowser.js)
|
|
||||||
function safeName(v) {
|
function safeName(v) {
|
||||||
return String(v || '').trim()
|
return String(v || '').trim()
|
||||||
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
|
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
|
||||||
@@ -35,86 +21,94 @@ function safeName(v) {
|
|||||||
.replace(/^-+|-+$/g, '');
|
.replace(/^-+|-+$/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload files to the correct Request Info/{rev} folder in FileBrowser.
|
export async function createProjectFolder(companyName, projectName) {
|
||||||
// companyName is required. versionNumber defaults to 0 (R00).
|
if (!companyName || !projectName) return;
|
||||||
// Best-effort — call with .catch(() => {}) so failures don't block submission.
|
const co = safeName(companyName);
|
||||||
|
const proj = safeName(projectName);
|
||||||
|
await fbq('mkdir', `/Clients/${co}/`);
|
||||||
|
await fbq('mkdir', `/Clients/${co}/Projects/`);
|
||||||
|
await fbq('mkdir', `/Clients/${co}/Projects/${proj}/`);
|
||||||
|
await fbq('mkdir', `/Clients/${co}/Projects/${proj}/00 Project Files/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTeamMemberFolder(name) {
|
||||||
|
if (!name) return;
|
||||||
|
await fbq('mkdir', '/Team/');
|
||||||
|
await fbq('mkdir', `/Team/${safeName(name)}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTaskFolder(companyName, projectName, taskTitle) {
|
||||||
|
if (!companyName || !projectName || !taskTitle) return;
|
||||||
|
const co = safeName(companyName);
|
||||||
|
const proj = safeName(projectName);
|
||||||
|
const task = safeName(taskTitle);
|
||||||
|
const base = `/Clients/${co}/Projects/${proj}/${task}`;
|
||||||
|
const mkdirs = [
|
||||||
|
`/Clients/${co}/`,
|
||||||
|
`/Clients/${co}/Projects/`,
|
||||||
|
`/Clients/${co}/Projects/${proj}/`,
|
||||||
|
`/Clients/${co}/Projects/${proj}/00 Project Files/`,
|
||||||
|
`${base}/`,
|
||||||
|
`${base}/Request Info/`,
|
||||||
|
`${base}/Old Book/`,
|
||||||
|
];
|
||||||
|
for (const dir of mkdirs) {
|
||||||
|
await fbq('mkdir', dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createClientFolder(companyName) {
|
||||||
|
if (!companyName) return;
|
||||||
|
await fbq('mkdir', '/Clients/');
|
||||||
|
await fbq('mkdir', `/Clients/${companyName}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function backfillClientFolders() {
|
||||||
|
const { data } = await supabase.from('companies').select('name');
|
||||||
|
if (!data?.length) return;
|
||||||
|
await fbq('mkdir', '/Clients/');
|
||||||
|
for (const company of data) {
|
||||||
|
if (company.name) await fbq('mkdir', `/Clients/${company.name}/`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function uploadFilesToRequestInfo(files, companyName, projectName, taskTitle, versionNumber = 0) {
|
export async function uploadFilesToRequestInfo(files, companyName, projectName, taskTitle, versionNumber = 0) {
|
||||||
if (!files?.length || !companyName || !projectName || !taskTitle) return;
|
if (!files?.length || !companyName || !projectName || !taskTitle) return;
|
||||||
|
|
||||||
const { data: { session } } = await supabase.auth.getSession();
|
|
||||||
if (!session?.access_token) return;
|
|
||||||
const authHeader = `Bearer ${session.access_token}`;
|
|
||||||
|
|
||||||
// Determine role
|
|
||||||
const { data: profile } = await supabase.from('profiles').select('role').eq('id', session.user.id).single();
|
|
||||||
const role = profile?.role;
|
|
||||||
|
|
||||||
const co = safeName(companyName);
|
const co = safeName(companyName);
|
||||||
const proj = safeName(projectName);
|
const proj = safeName(projectName);
|
||||||
const task = safeName(taskTitle);
|
const task = safeName(taskTitle);
|
||||||
const rev = `R${String(versionNumber).padStart(2, '0')}`;
|
const rev = `R${String(versionNumber).padStart(2, '0')}`;
|
||||||
|
const base = `/Clients/${co}/Projects/${proj}/${task}/Request Info/${rev}`;
|
||||||
|
|
||||||
// Build virtual path segments for mkdir.
|
const mkdirs = [
|
||||||
// Clients: virtual root is per-company; company folder already exists — start one level in.
|
`/Clients/${co}/`,
|
||||||
// Team/external: full path under /Clients/{co}/...
|
`/Clients/${co}/Projects/`,
|
||||||
let mkdirs;
|
`/Clients/${co}/Projects/${proj}/`,
|
||||||
let revPath;
|
`/Clients/${co}/Projects/${proj}/${task}/`,
|
||||||
|
`/Clients/${co}/Projects/${proj}/${task}/Request Info/`,
|
||||||
if (role === 'client') {
|
`/Clients/${co}/Projects/${proj}/${task}/Working Files/`,
|
||||||
revPath = `/${co}/Projects/${proj}/${task}/Request Info/${rev}`;
|
`/Clients/${co}/Projects/${proj}/${task}/Old Books/`,
|
||||||
mkdirs = [
|
`${base}/`,
|
||||||
{ path: `/${co}`, name: 'Projects' },
|
|
||||||
{ path: `/${co}/Projects`, name: proj },
|
|
||||||
{ path: `/${co}/Projects/${proj}`, name: task },
|
|
||||||
{ path: `/${co}/Projects/${proj}/${task}`, name: 'Request Info' },
|
|
||||||
{ path: `/${co}/Projects/${proj}/${task}/Request Info`, name: rev },
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
revPath = `/Clients/${co}/Projects/${proj}/${task}/Request Info/${rev}`;
|
|
||||||
mkdirs = [
|
|
||||||
{ path: '/Clients', name: co },
|
|
||||||
{ path: `/Clients/${co}`, name: 'Projects' },
|
|
||||||
{ path: `/Clients/${co}/Projects`, name: proj },
|
|
||||||
{ path: `/Clients/${co}/Projects/${proj}`, name: task },
|
|
||||||
{ path: `/Clients/${co}/Projects/${proj}/${task}`, name: 'Request Info' },
|
|
||||||
{ path: `/Clients/${co}/Projects/${proj}/${task}/Request Info`, name: rev },
|
|
||||||
];
|
];
|
||||||
|
for (const dir of mkdirs) {
|
||||||
|
await fbq('mkdir', dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const seg of mkdirs) {
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
await fetch('/api/filebrowser?action=mkdir', {
|
if (!session?.access_token) return;
|
||||||
method: 'POST',
|
|
||||||
headers: { Authorization: authHeader, 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(seg),
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get upload token for the revision folder
|
|
||||||
const tokenRes = await fetch('/api/filebrowser?action=upload-token', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { Authorization: authHeader, 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ path: revPath }),
|
|
||||||
}).catch(() => null);
|
|
||||||
if (!tokenRes?.ok) return;
|
|
||||||
|
|
||||||
const { token, url, fbPath } = await tokenRes.json();
|
|
||||||
if (!token || !url || !fbPath) return;
|
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
await fetch(`${url}/api/resources?source=files&path=${encodeURIComponent(`${fbPath}/${file.name}`)}&override=true`, {
|
const filePath = `${base}/${file.name}`;
|
||||||
|
const fd = new FormData(); fd.append('file', file);
|
||||||
|
await fetch(FBQ_FN, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': file.type || 'application/octet-stream' },
|
headers: {
|
||||||
body: file,
|
Authorization: `Bearer ${session.access_token}`,
|
||||||
|
'X-Operation': 'upload',
|
||||||
|
'X-Path': filePath,
|
||||||
|
},
|
||||||
|
body: fd,
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create missing /Clients/{name}/ folders for all companies.
|
|
||||||
export async function backfillClientFolders() {
|
|
||||||
const { data } = await supabase.from('companies').select('name');
|
|
||||||
if (!data?.length) return;
|
|
||||||
await fbCall('POST', 'mkdir', { path: '/', name: 'Clients' });
|
|
||||||
for (const company of data) {
|
|
||||||
if (company.name) await fbCall('POST', 'mkdir', { path: '/Clients', name: company.name });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { supabase } from '../lib/supabase';
|
|||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { deleteCompanyData } from '../lib/deleteHelpers';
|
import { deleteCompanyData } from '../lib/deleteHelpers';
|
||||||
import { readPageCache, writePageCache } from '../lib/pageCache';
|
import { readPageCache, writePageCache } from '../lib/pageCache';
|
||||||
import { createClientFolder, backfillClientFolders } from '../lib/filebrowserFolders';
|
import { createClientFolder, backfillClientFolders, createTeamMemberFolder } from '../lib/filebrowserFolders';
|
||||||
|
|
||||||
// ── Team view ─────────────────────────────────────────────────────────────────
|
// ── Team view ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -113,6 +113,9 @@ function TeamCompanies() {
|
|||||||
const errBody = error?.context ? await error.context.json().catch(() => null) : null;
|
const errBody = error?.context ? await error.context.json().catch(() => null) : null;
|
||||||
const errMsg = errBody?.error || data?.error || error?.message;
|
const errMsg = errBody?.error || data?.error || error?.message;
|
||||||
if (errMsg) { setUserError(errMsg); return; }
|
if (errMsg) { setUserError(errMsg); return; }
|
||||||
|
if (userForm.role === 'team' || userForm.role === 'external') {
|
||||||
|
createTeamMemberFolder(userForm.name.trim()).catch(() => {});
|
||||||
|
}
|
||||||
setShowNewUser(false);
|
setShowNewUser(false);
|
||||||
setUserForm({ name: '', email: '', password: '', company_id: '', role: 'client' });
|
setUserForm({ name: '', email: '', password: '', company_id: '', role: 'client' });
|
||||||
load();
|
load();
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { supabase } from '../lib/supabase';
|
|||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { serviceTypes } from '../data/mockData';
|
import { serviceTypes } from '../data/mockData';
|
||||||
import { cleanupTaskStorage, deleteCompanyData } from '../lib/deleteHelpers';
|
import { cleanupTaskStorage, deleteCompanyData } from '../lib/deleteHelpers';
|
||||||
import { renameClientFolder, backfillClientFolders } from '../lib/filebrowserFolders';
|
|
||||||
import { logActivity } from '../lib/activityLog';
|
import { logActivity } from '../lib/activityLog';
|
||||||
|
import { createProjectFolder } from '../lib/filebrowserFolders';
|
||||||
|
|
||||||
export default function CompanyDetail() {
|
export default function CompanyDetail() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
@@ -73,8 +73,6 @@ export default function CompanyDetail() {
|
|||||||
setSavingName(true);
|
setSavingName(true);
|
||||||
const oldName = company.name;
|
const oldName = company.name;
|
||||||
await supabase.from('companies').update({ name: nameVal.trim() }).eq('id', id);
|
await supabase.from('companies').update({ name: nameVal.trim() }).eq('id', id);
|
||||||
renameClientFolder(oldName, nameVal.trim()).catch(() => {});
|
|
||||||
backfillClientFolders().catch(() => {});
|
|
||||||
setCompany(c => ({ ...c, name: nameVal.trim() }));
|
setCompany(c => ({ ...c, name: nameVal.trim() }));
|
||||||
setEditingName(false);
|
setEditingName(false);
|
||||||
setSavingName(false);
|
setSavingName(false);
|
||||||
@@ -176,19 +174,10 @@ export default function CompanyDetail() {
|
|||||||
}).select().single();
|
}).select().single();
|
||||||
if (data) {
|
if (data) {
|
||||||
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'project_created', projectId: data.id, projectName: data.name });
|
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'project_created', projectId: data.id, projectName: data.name });
|
||||||
|
createProjectFolder(company.name, data.name).catch(() => {});
|
||||||
setProjects(prev => [data, ...prev]);
|
setProjects(prev => [data, ...prev]);
|
||||||
setNewProjectName('');
|
setNewProjectName('');
|
||||||
setShowNewProject(false);
|
setShowNewProject(false);
|
||||||
// Fire-and-forget: create project folder in FileBrowser
|
|
||||||
supabase.auth.getSession().then(({ data: { session } }) => {
|
|
||||||
if (session?.access_token && company?.name) {
|
|
||||||
fetch('/api/sync-project-folder', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.access_token}` },
|
|
||||||
body: JSON.stringify({ type: 'INSERT', record: { name: data.name, company_name: company.name } }),
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
setSavingProject(false);
|
setSavingProject(false);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,16 +2,15 @@ import { useState, useEffect } from 'react';
|
|||||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
import { useParams, 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 FileBrowser from '../components/FileBrowser';
|
|
||||||
import SortTh from '../components/SortTh';
|
import SortTh from '../components/SortTh';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { serviceTypes } from '../data/mockData';
|
import { serviceTypes } from '../data/mockData';
|
||||||
import { addDaysToDateOnly, getTodayDateOnlyEST } from '../lib/dates';
|
import { addDaysToDateOnly, getTodayDateOnlyEST } from '../lib/dates';
|
||||||
import { logActivity } from '../lib/activityLog';
|
import { logActivity } from '../lib/activityLog';
|
||||||
|
import { createTaskFolder } from '../lib/filebrowserFolders';
|
||||||
import { useSortable } from '../hooks/useSortable';
|
import { useSortable } from '../hooks/useSortable';
|
||||||
|
|
||||||
const safeFbName = v => String(v || '').trim().replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-').replace(/\s+/g, ' ').replace(/^-+|-+$/g, '');
|
|
||||||
|
|
||||||
const rLabel = (v) => 'R' + String(v || 0).padStart(2, '0');
|
const rLabel = (v) => 'R' + String(v || 0).padStart(2, '0');
|
||||||
const emptyJobForm = () => ({ title: '', serviceType: '', deadline: addDaysToDateOnly(getTodayDateOnlyEST(), 3), description: '', requestedBy: '', isHot: false });
|
const emptyJobForm = () => ({ title: '', serviceType: '', deadline: addDaysToDateOnly(getTodayDateOnlyEST(), 3), description: '', requestedBy: '', isHot: false });
|
||||||
@@ -149,6 +148,7 @@ export default function ProjectDetailPage() {
|
|||||||
if (task) {
|
if (task) {
|
||||||
await supabase.from('submissions').insert({ task_id: task.id, version_number: 0, type: 'initial', is_hot: jobForm.isHot, service_type: jobForm.serviceType, deadline: jobForm.deadline || null, description: jobForm.description.trim() || null, submitted_by: requestor.id, submitted_by_name: requestor.name.replace(' (You)', '') });
|
await supabase.from('submissions').insert({ task_id: task.id, version_number: 0, type: 'initial', is_hot: jobForm.isHot, service_type: jobForm.serviceType, deadline: jobForm.deadline || null, description: jobForm.description.trim() || null, submitted_by: requestor.id, submitted_by_name: requestor.name.replace(' (You)', '') });
|
||||||
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'task_created', taskId: task.id, taskTitle: task.title, projectId: id, projectName: project?.name });
|
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'task_created', taskId: task.id, taskTitle: task.title, projectId: id, projectName: project?.name });
|
||||||
|
createTaskFolder(company?.name, project?.name, task.title).catch(() => {});
|
||||||
setTasks(prev => [task, ...prev]);
|
setTasks(prev => [task, ...prev]);
|
||||||
setJobForm(emptyJobForm());
|
setJobForm(emptyJobForm());
|
||||||
setShowAddJob(false);
|
setShowAddJob(false);
|
||||||
@@ -315,20 +315,6 @@ export default function ProjectDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Project Folder (FileBrowser) — team + client */}
|
|
||||||
{!isExternal && company?.name && project?.name && (() => {
|
|
||||||
const co = safeFbName(company.name);
|
|
||||||
const proj = safeFbName(project.name);
|
|
||||||
const fbRoot = isClient
|
|
||||||
? `/${co}/Projects/${proj}/00 Project Files`
|
|
||||||
: `/Clients/${co}/Projects/${proj}/00 Project Files`;
|
|
||||||
return (
|
|
||||||
<div className="card" style={{ marginBottom: 24 }}>
|
|
||||||
<div className="card-title">Project Files</div>
|
|
||||||
<FileBrowser initialPath={fbRoot} rootPath={fbRoot} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* Client: mine/all filter */}
|
{/* Client: mine/all filter */}
|
||||||
{isClient && (
|
{isClient && (
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { sendEmail } from '../lib/email';
|
|||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { serviceTypes } from '../data/mockData';
|
import { serviceTypes } from '../data/mockData';
|
||||||
import { addDaysToDateOnly, formatDateEST, getTodayDateOnlyEST } from '../lib/dates';
|
import { addDaysToDateOnly, formatDateEST, getTodayDateOnlyEST } from '../lib/dates';
|
||||||
import { uploadFilesToRequestInfo } from '../lib/filebrowserFolders';
|
import { uploadFilesToRequestInfo, createProjectFolder } from '../lib/filebrowserFolders';
|
||||||
import { logActivity } from '../lib/activityLog';
|
import { logActivity } from '../lib/activityLog';
|
||||||
|
|
||||||
const rLabel = (v) => 'R' + String(v || 0).padStart(2, '0');
|
const rLabel = (v) => 'R' + String(v || 0).padStart(2, '0');
|
||||||
@@ -426,6 +426,7 @@ export default function RequestDetail() {
|
|||||||
try {
|
try {
|
||||||
const { data: newProj, error } = await supabase.from('projects').insert({ company_id: company.id, name: newProjectName.trim(), status: 'active' }).select().single();
|
const { data: newProj, error } = await supabase.from('projects').insert({ company_id: company.id, name: newProjectName.trim(), status: 'active' }).select().single();
|
||||||
if (error) throw new Error(error.message);
|
if (error) throw new Error(error.message);
|
||||||
|
createProjectFolder(company.name, newProj.name).catch(() => {});
|
||||||
await supabase.from('tasks').update({ project_id: newProj.id }).eq('id', id);
|
await supabase.from('tasks').update({ project_id: newProj.id }).eq('id', id);
|
||||||
setTask(t => ({ ...t, project_id: newProj.id }));
|
setTask(t => ({ ...t, project_id: newProj.id }));
|
||||||
setProject(p => ({ ...p, id: newProj.id, name: newProj.name }));
|
setProject(p => ({ ...p, id: newProj.id, name: newProj.name }));
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { getCurrentVersionForTask, getDeadlineSourceSubmission } from '../lib/ta
|
|||||||
import { formatDateOnly, fmtShortDate } from '../lib/dates';
|
import { formatDateOnly, fmtShortDate } from '../lib/dates';
|
||||||
import { createInitialSubmissionForRequest, createTaskForRequest, findOrCreateProject } from '../lib/requestSubmission';
|
import { createInitialSubmissionForRequest, createTaskForRequest, findOrCreateProject } from '../lib/requestSubmission';
|
||||||
import { sendEmail } from '../lib/email';
|
import { sendEmail } from '../lib/email';
|
||||||
import { uploadFilesToRequestInfo } from '../lib/filebrowserFolders';
|
import { uploadFilesToRequestInfo, createTaskFolder } from '../lib/filebrowserFolders';
|
||||||
import SortTh from '../components/SortTh';
|
import SortTh from '../components/SortTh';
|
||||||
import { useSortable } from '../hooks/useSortable';
|
import { useSortable } from '../hooks/useSortable';
|
||||||
import FilterDropdown from '../components/FilterDropdown';
|
import FilterDropdown from '../components/FilterDropdown';
|
||||||
@@ -185,6 +185,8 @@ export default function RequestsPage() {
|
|||||||
if (!projects.some(p => p.id === resolvedProject.id)) setProjects(prev => [...prev, resolvedProject]);
|
if (!projects.some(p => p.id === resolvedProject.id)) setProjects(prev => [...prev, resolvedProject]);
|
||||||
const { task } = await createTaskForRequest({ projectId: resolvedProject.id, title: formData.title.trim(), requestKey: addRequestKey });
|
const { task } = await createTaskForRequest({ projectId: resolvedProject.id, title: formData.title.trim(), requestKey: addRequestKey });
|
||||||
if (!task) throw new Error('Failed to create task.');
|
if (!task) throw new Error('Failed to create task.');
|
||||||
|
const taskCompany = companies?.find(c => c.id === formData.companyId);
|
||||||
|
createTaskFolder(taskCompany?.name, resolvedProject.name, formData.title.trim()).catch(() => {});
|
||||||
const { submission: sub } = await createInitialSubmissionForRequest({
|
const { submission: sub } = await createInitialSubmissionForRequest({
|
||||||
taskId: task.id, requestKey: addRequestKey, isHot: formData.isHot,
|
taskId: task.id, requestKey: addRequestKey, isHot: formData.isHot,
|
||||||
serviceType: formData.serviceType, deadline: formData.deadline,
|
serviceType: formData.serviceType, deadline: formData.deadline,
|
||||||
@@ -213,6 +215,7 @@ export default function RequestsPage() {
|
|||||||
const resolvedProject = await findOrCreateProject(formData.companyId, formData.project.trim(), existingProjects);
|
const resolvedProject = await findOrCreateProject(formData.companyId, formData.project.trim(), existingProjects);
|
||||||
const { task } = await createTaskForRequest({ projectId: resolvedProject.id, title: formData.title.trim(), requestKey: addRequestKey });
|
const { task } = await createTaskForRequest({ projectId: resolvedProject.id, title: formData.title.trim(), requestKey: addRequestKey });
|
||||||
if (!task) throw new Error('Failed to create task.');
|
if (!task) throw new Error('Failed to create task.');
|
||||||
|
createTaskFolder(selectedCompany?.name, resolvedProject.name, formData.title.trim()).catch(() => {});
|
||||||
const { submission } = await createInitialSubmissionForRequest({
|
const { submission } = await createInitialSubmissionForRequest({
|
||||||
taskId: task.id, requestKey: addRequestKey, isHot: formData.isHot,
|
taskId: task.id, requestKey: addRequestKey, isHot: formData.isHot,
|
||||||
serviceType: formData.serviceType, deadline: formData.deadline,
|
serviceType: formData.serviceType, deadline: formData.deadline,
|
||||||
|
|||||||
@@ -1,8 +1,49 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
|
import SortTh from '../components/SortTh';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { useSortable } from '../hooks/useSortable';
|
||||||
|
|
||||||
|
function smoothCurve(pts) {
|
||||||
|
if (pts.length < 2) return '';
|
||||||
|
let d = `M${pts[0][0].toFixed(1)},${pts[0][1].toFixed(1)}`;
|
||||||
|
for (let i = 1; i < pts.length; i++) {
|
||||||
|
const [x0, y0] = pts[i - 1]; const [x1, y1] = pts[i];
|
||||||
|
const cpX = (x0 + x1) / 2;
|
||||||
|
d += ` C${cpX.toFixed(1)},${y0.toFixed(1)} ${cpX.toFixed(1)},${y1.toFixed(1)} ${x1.toFixed(1)},${y1.toFixed(1)}`;
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiniAreaChart({ data, color = '#F5A523', gradId = 'ag1' }) {
|
||||||
|
const W = 90, H = 42;
|
||||||
|
if (!data || data.length < 2) return null;
|
||||||
|
const max = Math.max(...data, 1);
|
||||||
|
const pts = data.map((v, i) => [(i / (data.length - 1)) * W, 4 + (1 - v / max) * (H - 8)]);
|
||||||
|
const line = smoothCurve(pts);
|
||||||
|
const area = `${line} L${pts[pts.length-1][0].toFixed(1)},${H} L${pts[0][0].toFixed(1)},${H} Z`;
|
||||||
|
return (
|
||||||
|
<svg width="100%" height={H} viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" style={{ display: 'block', overflow: 'hidden' }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor={color} stopOpacity="0.3" />
|
||||||
|
<stop offset="95%" stopColor={color} stopOpacity="0" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path d={area} fill={`url(#${gradId})`} />
|
||||||
|
<path d={line} fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTaskStatus(status) {
|
||||||
|
if (status === 'in_progress') return 'In Progress';
|
||||||
|
if (status === 'on_hold') return 'On Hold';
|
||||||
|
if (status === 'client_review') return 'In Review';
|
||||||
|
return status ? status.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) : '—';
|
||||||
|
}
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const { currentUser } = useAuth();
|
const { currentUser } = useAuth();
|
||||||
@@ -18,15 +59,15 @@ export default function ProfilePage() {
|
|||||||
const [editError, setEditError] = useState('');
|
const [editError, setEditError] = useState('');
|
||||||
const [editForm, setEditForm] = useState({
|
const [editForm, setEditForm] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
title: '',
|
|
||||||
email: '',
|
email: '',
|
||||||
website: '',
|
website: '',
|
||||||
linkedin: '',
|
linkedin: '',
|
||||||
instagram: '',
|
|
||||||
twitter: '',
|
|
||||||
});
|
});
|
||||||
|
const [avatarUploading, setAvatarUploading] = useState(false);
|
||||||
const [activityItems, setActivityItems] = useState([]);
|
const [activityItems, setActivityItems] = useState([]);
|
||||||
const [profileStats, setProfileStats] = useState(null);
|
const [profileStats, setProfileStats] = useState(null);
|
||||||
|
const [profileTaskItems, setProfileTaskItems] = useState([]);
|
||||||
|
|
||||||
const isSelfView = !profileId || profileId === currentUser?.id;
|
const isSelfView = !profileId || profileId === currentUser?.id;
|
||||||
|
|
||||||
@@ -43,17 +84,8 @@ export default function ProfilePage() {
|
|||||||
setLoadingProfile(true);
|
setLoadingProfile(true);
|
||||||
setProfileError('');
|
setProfileError('');
|
||||||
try {
|
try {
|
||||||
const [{ data: profile, error }, { data: assignedTasks }] = await Promise.all([
|
const [{ data: profile, error }] = await Promise.all([
|
||||||
supabase
|
supabase.from('profiles').select('*').eq('id', profileId).single(),
|
||||||
.from('profiles')
|
|
||||||
.select('*')
|
|
||||||
.eq('id', profileId)
|
|
||||||
.single(),
|
|
||||||
supabase
|
|
||||||
.from('tasks')
|
|
||||||
.select('id, title, deadline, status')
|
|
||||||
.eq('assigned_to', profileId)
|
|
||||||
.not('deadline', 'is', null),
|
|
||||||
]);
|
]);
|
||||||
if (error || !profile) {
|
if (error || !profile) {
|
||||||
setProfileError('Unable to load profile.');
|
setProfileError('Unable to load profile.');
|
||||||
@@ -82,16 +114,6 @@ export default function ProfilePage() {
|
|||||||
setViewedProfile(profile);
|
setViewedProfile(profile);
|
||||||
setViewedCompanies([...names]);
|
setViewedCompanies([...names]);
|
||||||
setPrimaryCompanyAddress(primaryAddress);
|
setPrimaryCompanyAddress(primaryAddress);
|
||||||
setCalendarItems(
|
|
||||||
(assignedTasks || []).map((t) => ({
|
|
||||||
id: t.id,
|
|
||||||
title: t.title,
|
|
||||||
deadline: t.deadline,
|
|
||||||
isDone: ['client_approved', 'invoiced', 'paid'].includes(t.status),
|
|
||||||
isOverdue: !!t.deadline && !['client_approved', 'invoiced', 'paid'].includes(t.status) && new Date(t.deadline) < new Date(),
|
|
||||||
isHot: t.status === 'revision_requested',
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!cancelled) setProfileError('Unable to load profile.');
|
if (!cancelled) setProfileError('Unable to load profile.');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -110,24 +132,49 @@ export default function ProfilePage() {
|
|||||||
const uid = isSelfView ? currentUser?.id : profileId;
|
const uid = isSelfView ? currentUser?.id : profileId;
|
||||||
if (!uid) return;
|
if (!uid) return;
|
||||||
const doneStatuses = ['client_approved', 'invoiced', 'paid'];
|
const doneStatuses = ['client_approved', 'invoiced', 'paid'];
|
||||||
|
const now = new Date();
|
||||||
|
const weeksBack = 10;
|
||||||
|
const monthsBack = 8;
|
||||||
|
const weekStart = new Date(now); weekStart.setDate(weekStart.getDate() - weeksBack * 7);
|
||||||
|
const monthStart = new Date(now); monthStart.setMonth(monthStart.getMonth() - monthsBack);
|
||||||
|
|
||||||
Promise.all([
|
Promise.all([
|
||||||
supabase.from('tasks').select('id', { count: 'exact', head: true }).eq('assigned_to', uid).in('status', doneStatuses),
|
supabase.from('tasks').select('id', { count: 'exact', head: true }).eq('assigned_to', uid).in('status', doneStatuses),
|
||||||
|
supabase.from('tasks').select('completed_at').eq('assigned_to', uid).in('status', doneStatuses).not('completed_at', 'is', null).gte('completed_at', weekStart.toISOString()),
|
||||||
supabase.from('project_members').select('project_id').eq('profile_id', uid),
|
supabase.from('project_members').select('project_id').eq('profile_id', uid),
|
||||||
supabase.from('tasks').select('id', { count: 'exact', head: true }).eq('assigned_to', uid).eq('status', 'revision_requested'),
|
supabase.from('tasks').select('project_id, submitted_at').eq('assigned_to', uid).not('project_id', 'is', null).gte('submitted_at', monthStart.toISOString()),
|
||||||
supabase.from('submissions').select('id', { count: 'exact', head: true }).eq('submitted_by', uid),
|
]).then(([completedTotal, completedRecent, members, tasksByMonth]) => {
|
||||||
]).then(([completed, members, revisions, submissions]) => {
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
|
// weekly chart: count completed tasks per week (oldest → newest)
|
||||||
|
const weekBuckets = Array(weeksBack).fill(0);
|
||||||
|
(completedRecent.data || []).forEach(t => {
|
||||||
|
const msAgo = now - new Date(t.completed_at);
|
||||||
|
const weekIdx = weeksBack - 1 - Math.floor(msAgo / (7 * 86400000));
|
||||||
|
if (weekIdx >= 0 && weekIdx < weeksBack) weekBuckets[weekIdx]++;
|
||||||
|
});
|
||||||
|
|
||||||
|
// monthly chart: distinct active project_ids per month
|
||||||
|
const monthBuckets = Array(monthsBack).fill(0);
|
||||||
|
const monthSets = Array.from({ length: monthsBack }, () => new Set());
|
||||||
|
(tasksByMonth.data || []).forEach(t => {
|
||||||
|
const msAgo = now - new Date(t.submitted_at);
|
||||||
|
const mIdx = monthsBack - 1 - Math.floor(msAgo / (30.44 * 86400000));
|
||||||
|
if (mIdx >= 0 && mIdx < monthsBack) monthSets[mIdx].add(t.project_id);
|
||||||
|
});
|
||||||
|
monthSets.forEach((s, i) => { monthBuckets[i] = s.size; });
|
||||||
|
|
||||||
const projectIds = (members.data || []).map(m => m.project_id);
|
const projectIds = (members.data || []).map(m => m.project_id);
|
||||||
const fetchActiveProjects = projectIds.length > 0
|
const fetchActive = projectIds.length > 0
|
||||||
? supabase.from('projects').select('id', { count: 'exact', head: true }).in('id', projectIds).not('status', 'in', '("completed","cancelled")')
|
? supabase.from('projects').select('id', { count: 'exact', head: true }).in('id', projectIds).not('status', 'in', '("completed","cancelled")')
|
||||||
: Promise.resolve({ count: 0 });
|
: Promise.resolve({ count: 0 });
|
||||||
fetchActiveProjects.then(active => {
|
fetchActive.then(active => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setProfileStats({
|
setProfileStats({
|
||||||
tasksCompleted: completed.count ?? 0,
|
tasksCompleted: completedTotal.count ?? 0,
|
||||||
|
tasksChart: weekBuckets,
|
||||||
activeProjects: active.count ?? 0,
|
activeProjects: active.count ?? 0,
|
||||||
revisionRequests: revisions.count ?? 0,
|
projectsChart: monthBuckets,
|
||||||
submissions: submissions.count ?? 0,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -138,23 +185,69 @@ export default function ProfilePage() {
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const uid = isSelfView ? currentUser?.id : profileId;
|
const uid = isSelfView ? currentUser?.id : profileId;
|
||||||
if (!uid) return;
|
if (!uid) return;
|
||||||
|
|
||||||
|
async function loadProfileTasks() {
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('tasks')
|
||||||
|
.select('id, title, status, submitted_at')
|
||||||
|
.eq('assigned_to', uid)
|
||||||
|
.in('status', ['in_progress', 'on_hold', 'client_review'])
|
||||||
|
.order('submitted_at', { ascending: false })
|
||||||
|
.limit(20);
|
||||||
|
|
||||||
|
if (!cancelled) setProfileTaskItems(data || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadProfileTasks();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [isSelfView, currentUser?.id, profileId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const uid = isSelfView ? currentUser?.id : profileId;
|
||||||
|
if (!uid) return;
|
||||||
|
async function loadActivity() {
|
||||||
|
const [{ data: actorLogs }, { data: assignedTasks }] = await Promise.all([
|
||||||
supabase
|
supabase
|
||||||
.from('activity_log')
|
.from('activity_log')
|
||||||
.select('id, created_at, action, task_id, task_title, project_name, project_id')
|
.select('id, created_at, action, task_id, task_title, project_name, project_id')
|
||||||
.eq('actor_id', uid)
|
.eq('actor_id', uid)
|
||||||
.order('created_at', { ascending: false })
|
.order('created_at', { ascending: false })
|
||||||
.limit(10)
|
.limit(20),
|
||||||
.then(({ data }) => {
|
supabase
|
||||||
|
.from('tasks')
|
||||||
|
.select('id')
|
||||||
|
.eq('assigned_to', uid),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const assignedTaskIds = (assignedTasks || []).map((t) => t.id).filter(Boolean);
|
||||||
|
let outcomeLogs = [];
|
||||||
|
if (assignedTaskIds.length > 0) {
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('activity_log')
|
||||||
|
.select('id, created_at, action, task_id, task_title, project_name, project_id')
|
||||||
|
.in('action', ['task_approved', 'revision_requested'])
|
||||||
|
.in('task_id', assignedTaskIds)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(20);
|
||||||
|
outcomeLogs = data || [];
|
||||||
|
}
|
||||||
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setActivityItems(data || []);
|
const merged = [...(actorLogs || []), ...outcomeLogs];
|
||||||
});
|
const deduped = Array.from(new Map(merged.map((item) => [item.id, item])).values());
|
||||||
|
deduped.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||||
|
setActivityItems(deduped.slice(0, 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
loadActivity();
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [isSelfView, currentUser?.id, profileId]);
|
}, [isSelfView, currentUser?.id, profileId]);
|
||||||
|
|
||||||
const profile = useMemo(
|
const profile = useMemo(
|
||||||
() => isSelfView ? { ...(currentUser || {}), ...(viewedProfile || {}) } : viewedProfile,
|
() => isSelfView ? { ...(currentUser || {}), ...(viewedProfile || {}) } : viewedProfile,
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[isSelfView, currentUser?.id, currentUser?.name, currentUser?.title, currentUser?.email, currentUser?.role, currentUser?.website, currentUser?.linkedin, currentUser?.instagram, currentUser?.twitter, viewedProfile]
|
[isSelfView, currentUser?.id, currentUser?.name, currentUser?.email, currentUser?.role, currentUser?.website, currentUser?.linkedin, currentUser?.avatar_url, viewedProfile]
|
||||||
);
|
);
|
||||||
const companyNames = useMemo(() => {
|
const companyNames = useMemo(() => {
|
||||||
if (isSelfView) {
|
if (isSelfView) {
|
||||||
@@ -168,6 +261,12 @@ export default function ProfilePage() {
|
|||||||
if (isSelfView) return currentUser?.company?.address || currentUser?.companies?.[0]?.company?.address || '';
|
if (isSelfView) return currentUser?.company?.address || currentUser?.companies?.[0]?.company?.address || '';
|
||||||
return primaryCompanyAddress || '';
|
return primaryCompanyAddress || '';
|
||||||
}, [isSelfView, currentUser, primaryCompanyAddress]);
|
}, [isSelfView, currentUser, primaryCompanyAddress]);
|
||||||
|
const companyDisplayName = useMemo(() => {
|
||||||
|
const role = profile?.role;
|
||||||
|
if (role === 'team' || role === 'external') return 'Fourge Branding';
|
||||||
|
if (companyNames.length > 0) return companyNames.join(', ');
|
||||||
|
return 'No Company';
|
||||||
|
}, [companyNames, profile?.role]);
|
||||||
|
|
||||||
const memberSince = useMemo(() => {
|
const memberSince = useMemo(() => {
|
||||||
const d = profile?.created_at ? new Date(profile.created_at) : null;
|
const d = profile?.created_at ? new Date(profile.created_at) : null;
|
||||||
@@ -176,18 +275,23 @@ export default function ProfilePage() {
|
|||||||
: null;
|
: null;
|
||||||
}, [profile?.created_at]);
|
}, [profile?.created_at]);
|
||||||
|
|
||||||
const socialLinks = useMemo(() => {
|
const handleAvatarUpload = async (e) => {
|
||||||
if (!profile) return [];
|
const file = e.target.files?.[0];
|
||||||
const candidates = [
|
if (!file || !currentUser?.id) return;
|
||||||
{ key: 'website', label: 'Website' },
|
setAvatarUploading(true);
|
||||||
{ key: 'linkedin', label: 'LinkedIn' },
|
try {
|
||||||
{ key: 'instagram', label: 'Instagram' },
|
const ext = file.name.split('.').pop();
|
||||||
{ key: 'twitter', label: 'X / Twitter' },
|
const path = `${currentUser.id}/avatar.${ext}`;
|
||||||
];
|
const { error: upErr } = await supabase.storage.from('avatars').upload(path, file, { upsert: true });
|
||||||
return candidates
|
if (upErr) { setEditError(upErr.message); return; }
|
||||||
.map(({ key, label }) => ({ label, value: profile[key] }))
|
const { data: { publicUrl } } = supabase.storage.from('avatars').getPublicUrl(path);
|
||||||
.filter((item) => typeof item.value === 'string' && item.value.trim().length > 0);
|
const { data, error: dbErr } = await supabase.from('profiles').update({ avatar_url: publicUrl }).eq('id', currentUser.id).select('*').single();
|
||||||
}, [profile]);
|
if (dbErr) { setEditError(dbErr.message); return; }
|
||||||
|
setViewedProfile(data);
|
||||||
|
} finally {
|
||||||
|
setAvatarUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
const dashCardStyle = {
|
const dashCardStyle = {
|
||||||
background: 'var(--card-bg)',
|
background: 'var(--card-bg)',
|
||||||
border: '1px solid var(--border)',
|
border: '1px solid var(--border)',
|
||||||
@@ -208,16 +312,14 @@ export default function ProfilePage() {
|
|||||||
if (!profile) return;
|
if (!profile) return;
|
||||||
setEditForm({
|
setEditForm({
|
||||||
name: profile.name || '',
|
name: profile.name || '',
|
||||||
title: profile.title || '',
|
|
||||||
email: profile.email || '',
|
email: profile.email || '',
|
||||||
website: profile.website || '',
|
website: profile.website || '',
|
||||||
linkedin: profile.linkedin || '',
|
linkedin: profile.linkedin || '',
|
||||||
instagram: profile.instagram || '',
|
|
||||||
twitter: profile.twitter || '',
|
|
||||||
});
|
});
|
||||||
setEditError('');
|
setEditError('');
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [profile?.name, profile?.title, profile?.email, profile?.website, profile?.linkedin, profile?.instagram, profile?.twitter]);
|
}, [profile?.name, profile?.email, profile?.website, profile?.linkedin]);
|
||||||
|
|
||||||
const setEditField = (field) => (e) => setEditForm((prev) => ({ ...prev, [field]: e.target.value }));
|
const setEditField = (field) => (e) => setEditForm((prev) => ({ ...prev, [field]: e.target.value }));
|
||||||
|
|
||||||
@@ -229,12 +331,10 @@ export default function ProfilePage() {
|
|||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
name: editForm.name.trim(),
|
name: editForm.name.trim(),
|
||||||
title: editForm.title.trim(),
|
|
||||||
email: editForm.email.trim(),
|
email: editForm.email.trim(),
|
||||||
website: editForm.website.trim(),
|
website: editForm.website.trim(),
|
||||||
linkedin: editForm.linkedin.trim(),
|
linkedin: editForm.linkedin.trim(),
|
||||||
instagram: editForm.instagram.trim(),
|
|
||||||
twitter: editForm.twitter.trim(),
|
|
||||||
};
|
};
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
@@ -254,10 +354,111 @@ export default function ProfilePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const ACTION_ICON = {
|
||||||
|
task_started: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: <polygon points="6,4 20,12 6,20" fill="currentColor"/> },
|
||||||
|
task_resumed: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: <polygon points="6,4 20,12 6,20" fill="currentColor"/> },
|
||||||
|
task_submitted: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: <><line x1="12" y1="19" x2="12" y2="5" strokeWidth="2" strokeLinecap="round"/><polyline points="5,12 12,5 19,12" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/></> },
|
||||||
|
task_approved: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: <polyline points="4,13 9,18 20,7" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/> },
|
||||||
|
task_on_hold: { bg: 'rgba(239,68,68,0.15)', color: '#ef4444', path: <><rect x="6" y="4" width="4" height="16" rx="1" fill="currentColor"/><rect x="14" y="4" width="4" height="16" rx="1" fill="currentColor"/></> },
|
||||||
|
task_created: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: <><line x1="12" y1="5" x2="12" y2="19" strokeWidth="2" strokeLinecap="round"/><line x1="5" y1="12" x2="19" y2="12" strokeWidth="2" strokeLinecap="round"/></> },
|
||||||
|
project_created: { bg: 'rgba(245,165,35,0.15)', color: '#F5A523', path: <><path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" fill="none" strokeWidth="1.5"/></> },
|
||||||
|
request_submitted: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: <><rect x="5" y="3" width="14" height="18" rx="2" fill="none" strokeWidth="1.5"/><line x1="9" y1="8" x2="15" y2="8" strokeWidth="1.5" strokeLinecap="round"/><line x1="9" y1="12" x2="15" y2="12" strokeWidth="1.5" strokeLinecap="round"/><line x1="9" y1="16" x2="12" y2="16" strokeWidth="1.5" strokeLinecap="round"/></> },
|
||||||
|
revision_requested: { bg: 'rgba(245,158,11,0.15)', color: '#f59e0b', path: <><path d="M4 12a8 8 0 018-8v0a8 8 0 018 8" fill="none" strokeWidth="1.5" strokeLinecap="round"/><polyline points="18,8 20,12 16,12" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none"/></> },
|
||||||
|
};
|
||||||
|
const ActionIcon = ({ actionKey, size = 27 }) => {
|
||||||
|
const cfg = ACTION_ICON[actionKey] || { bg: 'rgba(255,255,255,0.08)', color: 'rgba(255,255,255,0.4)', path: <circle cx="12" cy="12" r="3" fill="currentColor"/> };
|
||||||
|
return (
|
||||||
|
<div style={{ width: size, height: size, borderRadius: '50%', background: cfg.bg, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" stroke={cfg.color} fill="none" style={{ color: cfg.color }}>{cfg.path}</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const ACTION_LABEL = {
|
const ACTION_LABEL = {
|
||||||
task_created: 'created', task_started: 'started', task_on_hold: 'put on hold',
|
task_created: 'Task created', task_started: 'Task started', task_on_hold: 'Task put on hold',
|
||||||
task_resumed: 'resumed', task_submitted: 'submitted', task_approved: 'approved',
|
task_resumed: 'Task resumed', task_submitted: 'Task submitted', task_approved: 'Task approved',
|
||||||
project_created: 'created project', request_submitted: 'submitted', revision_requested: 'requested revision on',
|
project_created: 'Project created', request_submitted: 'Request submitted', revision_requested: 'Task rejected',
|
||||||
|
};
|
||||||
|
|
||||||
|
const toSentenceTitle = (value = '') => {
|
||||||
|
if (!value) return '';
|
||||||
|
return value.charAt(0).toUpperCase() + value.slice(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const profileTitleStyle = { fontSize: 18, fontWeight: 500, color: 'var(--text-primary)', lineHeight: 1.2 };
|
||||||
|
const profileSubStyle = { fontSize: 13, color: 'var(--text-secondary)', marginTop: 3 };
|
||||||
|
const profileBodyStyle = { fontSize: 13, color: 'var(--text-primary)' };
|
||||||
|
const metaLabelStyle = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8 };
|
||||||
|
const metaValueStyle = { fontSize: 13, color: 'var(--text-primary)', marginTop: 2 };
|
||||||
|
const modalLabelStyle = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 6 };
|
||||||
|
const modalInputStyle = {
|
||||||
|
fontSize: 13,
|
||||||
|
padding: '0 5px',
|
||||||
|
textAlign: 'left',
|
||||||
|
lineHeight: 1,
|
||||||
|
};
|
||||||
|
const modalHelperStyle = { fontSize: 12, color: 'var(--text-muted)', marginTop: 6 };
|
||||||
|
const modalButtonStyle = {
|
||||||
|
lineHeight: 1,
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProfileTasksInProgressCard = ({ tasks = [] }) => {
|
||||||
|
const { sortKey, sortDir, toggle, sort } = useSortable('task');
|
||||||
|
const [showAll, setShowAll] = useState(false);
|
||||||
|
const rows = sort(tasks.slice(0, 10), (task, key) => {
|
||||||
|
if (key === 'task') return task.title || '';
|
||||||
|
if (key === 'status') return formatTaskStatus(task.status);
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
const visibleRows = showAll ? rows : rows.slice(0, 5);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ ...dashCardStyle, display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<div style={{ marginBottom: rows.length > 0 ? 14 : 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Tasks In Progress</span>
|
||||||
|
{rows.length > 5 && (
|
||||||
|
<button type="button" className="dashboard-inline-link" style={{ fontSize: 11, fontWeight: 500, letterSpacing: 0.4, textTransform: 'uppercase' }} onClick={() => setShowAll((value) => !value)}>
|
||||||
|
{showAll ? 'Show less' : 'Show all'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No tasks in progress</div>
|
||||||
|
) : (
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||||
|
<colgroup>
|
||||||
|
<col style={{ width: '65%' }} />
|
||||||
|
<col style={{ width: '35%' }} />
|
||||||
|
</colgroup>
|
||||||
|
<thead style={{ background: 'transparent' }}>
|
||||||
|
<tr style={{ background: 'transparent' }}>
|
||||||
|
<SortTh col="task" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'left', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 5px', border: 'none', background: 'transparent', verticalAlign: 'top' }}>
|
||||||
|
Task
|
||||||
|
</SortTh>
|
||||||
|
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'right', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 5px 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>
|
||||||
|
Status
|
||||||
|
</SortTh>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{visibleRows.map((task) => (
|
||||||
|
<tr key={task.id} style={{ background: 'transparent' }}>
|
||||||
|
<td style={{ fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', padding: '5px', border: 'none', background: 'transparent', textAlign: 'left' }}>
|
||||||
|
<button type="button" className="dashboard-inline-link" onClick={() => navigate(`/requests/${task.id}`)}>{task.title}</button>
|
||||||
|
</td>
|
||||||
|
<td style={{ fontSize: 12, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', padding: '5px', border: 'none', background: 'transparent', textAlign: 'right' }}>
|
||||||
|
{formatTaskStatus(task.status)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ProfileActivityFeed = ({ items }) => (
|
const ProfileActivityFeed = ({ items }) => (
|
||||||
@@ -269,8 +470,9 @@ export default function ProfilePage() {
|
|||||||
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 14 }}>No recent activity</div>
|
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 14 }}>No recent activity</div>
|
||||||
) : items.map((e, i) => (
|
) : items.map((e, i) => (
|
||||||
<div key={e.id} style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: i > 0 ? 10 : 0 }}>
|
<div key={e.id} style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: i > 0 ? 10 : 0 }}>
|
||||||
|
<ActionIcon actionKey={e.action} />
|
||||||
<div style={{ flex: 1, minWidth: 0, fontSize: 13, lineHeight: 1.4 }}>
|
<div style={{ flex: 1, minWidth: 0, fontSize: 13, lineHeight: 1.4 }}>
|
||||||
<span style={{ color: 'var(--text-muted)' }}>{ACTION_LABEL[e.action] || e.action}</span>
|
<span style={{ color: 'var(--text-muted)' }}>{toSentenceTitle(ACTION_LABEL[e.action] || e.action)}</span>
|
||||||
{e.task_title && e.task_id && (
|
{e.task_title && e.task_id && (
|
||||||
<><span style={{ color: 'var(--text-muted)' }}> </span>
|
<><span style={{ color: 'var(--text-muted)' }}> </span>
|
||||||
<button type="button" className="dashboard-inline-link" onClick={() => navigate(`/requests/${e.task_id}`)}>{e.task_title}</button></>
|
<button type="button" className="dashboard-inline-link" onClick={() => navigate(`/requests/${e.task_id}`)}>{e.task_title}</button></>
|
||||||
@@ -294,90 +496,115 @@ export default function ProfilePage() {
|
|||||||
<div className="profile-top-grid">
|
<div className="profile-top-grid">
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||||
<div style={{ ...dashCardStyle, display: 'flex', alignItems: 'flex-start', gap: 20, position: 'relative' }}>
|
<div style={{ ...dashCardStyle, display: 'flex', alignItems: 'flex-start', gap: 20, position: 'relative' }}>
|
||||||
{isSelfView && (
|
|
||||||
<div style={{ position: 'absolute', top: 18, right: 21, bottom: 18, display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'space-between' }}>
|
<div style={{ position: 'absolute', top: 18, right: 21, bottom: 18, display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'space-between' }}>
|
||||||
|
{isSelfView && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-outline"
|
className="btn btn-outline"
|
||||||
onClick={() => setEditOpen(true)}
|
onClick={() => setEditOpen(true)}
|
||||||
style={{ borderRadius: 8, height: 30, padding: '0 12px', fontSize: 12 }}
|
|
||||||
>
|
>
|
||||||
Edit Profile
|
Edit Profile
|
||||||
</button>
|
</button>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 10 }}>
|
)}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 10, marginTop: 'auto' }}>
|
||||||
{memberSince && (
|
{memberSince && (
|
||||||
<div style={{ textAlign: 'right' }}>
|
<div style={{ textAlign: 'right' }}>
|
||||||
<div style={{ fontSize: 10, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.6 }}>Member Since</div>
|
<div style={metaLabelStyle}>Member Since</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-primary)', marginTop: 2 }}>{memberSince}</div>
|
<div style={metaValueStyle}>{memberSince}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{profile?.role && (
|
{profile?.role && (
|
||||||
<div style={{ textAlign: 'right' }}>
|
<div style={{ textAlign: 'right' }}>
|
||||||
<div style={{ fontSize: 10, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.6 }}>Role</div>
|
<div style={metaLabelStyle}>Role</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-primary)', marginTop: 2, textTransform: 'capitalize' }}>{profile.role}</div>
|
<div style={metaValueStyle}>
|
||||||
</div>
|
{profile.role === 'team' ? 'Team' : profile.role === 'external' ? 'Subcontractor' : profile.role === 'client' ? 'Client' : profile.role}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div style={{ width: 120, height: 120, flexShrink: 0, borderRadius: '50%', background: 'var(--card-bg-2)', border: '2px solid #111', outline: '2px solid var(--accent)', outlineOffset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 36, color: 'var(--text-primary)', fontWeight: 500, lineHeight: 1 }}>
|
</div>
|
||||||
{initials || '?'}
|
</div>
|
||||||
|
<div style={{ width: 140, height: 140, flexShrink: 0, borderRadius: '50%', background: 'var(--card-bg-2)', border: '2px solid #111', outline: '2px solid var(--accent)', outlineOffset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 36, color: 'var(--text-primary)', fontWeight: 500, lineHeight: 1, overflow: 'hidden' }}>
|
||||||
|
{profile?.avatar_url
|
||||||
|
? <img src={profile.avatar_url} alt={profile.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
|
: (initials || '?')}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div style={{ fontWeight: 500, fontSize: 18, color: 'var(--text-primary)' }}>{profile?.name || '—'}</div>
|
<div style={profileTitleStyle}>{profile?.name || '—'}</div>
|
||||||
{profile?.title && <div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 3 }}>{profile.title}</div>}
|
<div style={profileSubStyle}>
|
||||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)', marginTop: 3 }}>
|
{companyDisplayName}
|
||||||
{companyNames.length > 0 ? companyNames.join(', ') : '—'}
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: 14, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div style={{ marginTop: 14, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
{companyAddress && (
|
{companyAddress && (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><path d="M12 22s-8-4.5-8-11.8A8 8 0 0112 2a8 8 0 018 8.2c0 7.3-8 11.8-8 11.8z"/><circle cx="12" cy="10" r="3"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0, position: 'relative', top: -2 }}><path d="M12 22s-8-4.5-8-11.8A8 8 0 0112 2a8 8 0 018 8.2c0 7.3-8 11.8-8 11.8z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||||
<span style={{ color: 'var(--text-primary)' }}>{companyAddress}</span>
|
<span style={profileBodyStyle}>{companyAddress}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{profile?.email && (
|
{profile?.email && (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><rect x="2" y="4" width="20" height="16" rx="2"/><polyline points="2,4 12,13 22,4"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0, position: 'relative', top: -2 }}><rect x="2" y="4" width="20" height="16" rx="2"/><polyline points="2,4 12,13 22,4"/></svg>
|
||||||
<span style={{ color: 'var(--text-primary)' }}>{profile.email}</span>
|
<span style={profileBodyStyle}>{profile.email}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{profile?.website && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0, position: 'relative', top: -2 }}><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 010 20M12 2a15.3 15.3 0 000 20"/></svg>
|
||||||
|
<a href={profile.website.startsWith('http') ? profile.website : `https://${profile.website}`} target="_blank" rel="noreferrer" style={{ ...profileBodyStyle, textDecoration: 'none' }}>{profile.website.replace(/^https?:\/\/(www\.)?/, '')}</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{profile?.linkedin && (
|
{profile?.linkedin && (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="var(--text-muted)" style={{ flexShrink: 0 }}><path d="M16 8a6 6 0 016 6v7h-4v-7a2 2 0 00-2-2 2 2 0 00-2 2v7h-4v-7a6 6 0 016-6z"/><rect x="2" y="9" width="4" height="12"/><circle cx="4" cy="4" r="2"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="var(--text-muted)" style={{ flexShrink: 0, position: 'relative', top: -2 }}><path d="M16 8a6 6 0 016 6v7h-4v-7a2 2 0 00-2-2 2 2 0 00-2 2v7h-4v-7a6 6 0 016-6z"/><rect x="2" y="9" width="4" height="12"/><circle cx="4" cy="4" r="2"/></svg>
|
||||||
<a href={profile.linkedin.startsWith('http') ? profile.linkedin : `https://${profile.linkedin}`} target="_blank" rel="noreferrer" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: 13 }}>{profile.linkedin.replace(/^https?:\/\/(www\.)?/, '')}</a>
|
<a href={profile.linkedin.startsWith('http') ? profile.linkedin : `https://${profile.linkedin}`} target="_blank" rel="noreferrer" style={{ ...profileBodyStyle, textDecoration: 'none' }}>{profile.linkedin.replace(/^https?:\/\/(www\.)?/, '')}</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{profileStats && (
|
{profileStats && (
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 24 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24 }}>
|
||||||
{[
|
{/* Tasks Completed — weekly */}
|
||||||
{ label: 'Tasks Completed', value: profileStats.tasksCompleted, iconBg: 'rgba(74,222,128,0.15)', iconColor: '#4ade80', iconPath: '<polyline points="4,13 9,18 20,7"/>' },
|
<div style={{ ...dashCardStyle, display: 'flex', flexDirection: 'column', minHeight: 120 }}>
|
||||||
{ label: 'Active Projects', value: profileStats.activeProjects, iconBg: 'rgba(245,165,35,0.15)', iconColor: '#F5A523', iconPath: '<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" fill="none"/>' },
|
<div style={{ display: 'flex', alignItems: 'stretch', gap: 21 }}>
|
||||||
{ label: 'Revision Requests',value: profileStats.revisionRequests, iconBg: 'rgba(239,68,68,0.15)', iconColor: '#f87171', iconPath: '<path d="M4 12a8 8 0 018-8v0a8 8 0 018 8" fill="none" stroke-linecap="round"/><polyline points="18,8 20,12 16,12" fill="none"/>' },
|
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0 }}>
|
||||||
{ label: 'Submissions', value: profileStats.submissions, iconBg: 'rgba(96,165,250,0.15)', iconColor: '#60a5fa', iconPath: '<line x1="12" y1="19" x2="12" y2="5" stroke-linecap="round"/><polyline points="5,12 12,5 19,12" fill="none"/>' },
|
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>Tasks Completed</div>
|
||||||
].map(({ label, value, iconBg, iconColor, iconPath }) => (
|
|
||||||
<div key={label} style={{ ...dashCardStyle, display: 'flex', alignItems: 'stretch', gap: 21, minHeight: 120 }}>
|
|
||||||
<div style={{ flexShrink: 0, display: 'flex', flexDirection: 'column', flex: 1 }}>
|
|
||||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
|
|
||||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center' }}>
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center' }}>
|
||||||
<div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{value}</div>
|
<div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{profileStats.tasksCompleted}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'space-between', flexShrink: 0 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||||
<div style={{ width: 27, height: 27, borderRadius: '50%', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<div style={{ width: 27, height: 27, borderRadius: '50%', background: 'rgba(74,222,128,0.15)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={iconColor} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" dangerouslySetInnerHTML={{ __html: iconPath }} />
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#4ade80" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" dangerouslySetInnerHTML={{ __html: '<polyline points="4,13 9,18 20,7"/>' }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
<MiniAreaChart data={profileStats.tasksChart} color="#4ade80" gradId="tcGrad" />
|
||||||
|
</div>
|
||||||
|
{/* Active Projects — monthly */}
|
||||||
|
<div style={{ ...dashCardStyle, display: 'flex', flexDirection: 'column', minHeight: 120 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'stretch', gap: 21 }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>Active Projects</div>
|
||||||
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center' }}>
|
||||||
|
<div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{profileStats.activeProjects}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||||
|
<div style={{ width: 27, height: 27, borderRadius: '50%', background: 'rgba(245,165,35,0.15)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#F5A523" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" dangerouslySetInnerHTML={{ __html: '<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" fill="none"/>' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<MiniAreaChart data={profileStats.projectsChart} color="#F5A523" gradId="apGrad" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||||
|
<ProfileTasksInProgressCard tasks={profileTaskItems} />
|
||||||
<ProfileActivityFeed items={activityItems} />
|
<ProfileActivityFeed items={activityItems} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isSelfView && editOpen && (
|
{isSelfView && editOpen && (
|
||||||
@@ -399,48 +626,53 @@ export default function ProfilePage() {
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
...dashCardStyle,
|
...dashCardStyle,
|
||||||
width: 'min(620px, 100%)',
|
width: 'min(434px, 100%)',
|
||||||
maxHeight: 'calc(100vh - 48px)',
|
maxHeight: 'calc(100vh - 48px)',
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
}}
|
}}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div style={{ fontSize: 18, fontWeight: 500, marginBottom: 14, color: 'var(--text-primary)' }}>Edit Profile</div>
|
<div style={{ ...metaLabelStyle, marginBottom: 14 }}>Edit Profile</div>
|
||||||
<form onSubmit={handleProfileSave}>
|
<form onSubmit={handleProfileSave}>
|
||||||
<div className="form-group">
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 20 }}>
|
||||||
<label>Name</label>
|
<div style={{ width: 72, height: 72, borderRadius: '50%', background: 'var(--card-bg-2)', border: '2px solid #111', outline: '2px solid var(--accent)', outlineOffset: 0, flexShrink: 0, overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 24, color: 'var(--text-primary)', fontWeight: 500 }}>
|
||||||
<input value={editForm.name} onChange={setEditField('name')} />
|
{profile?.avatar_url
|
||||||
|
? <img src={profile.avatar_url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
|
: (initials || '?')}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="avatar-upload" style={{ cursor: 'pointer', display: 'inline-block' }}>
|
||||||
|
<span className="btn btn-outline" style={{ ...modalButtonStyle, display: 'inline-flex', alignItems: 'center' }}>
|
||||||
|
{avatarUploading ? 'Uploading…' : 'Change Photo'}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input id="avatar-upload" type="file" accept="image/*" style={{ display: 'none' }} onChange={handleAvatarUpload} disabled={avatarUploading} />
|
||||||
|
<div style={modalHelperStyle}>JPG, PNG or WebP</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Title</label>
|
<label style={modalLabelStyle}>Name</label>
|
||||||
<input value={editForm.title} onChange={setEditField('title')} placeholder="e.g. Creative Director" />
|
<input type="text" value={editForm.name} onChange={setEditField('name')} style={modalInputStyle} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label style={modalLabelStyle}>Email</label>
|
||||||
|
<input type="email" value={editForm.email} onChange={setEditField('email')} style={modalInputStyle} />
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Email</label>
|
<label style={modalLabelStyle}>Website</label>
|
||||||
<input type="email" value={editForm.email} onChange={setEditField('email')} />
|
<input type="text" value={editForm.website} onChange={setEditField('website')} placeholder="example.com" style={modalInputStyle} />
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Website</label>
|
<label style={modalLabelStyle}>LinkedIn</label>
|
||||||
<input value={editForm.website} onChange={setEditField('website')} placeholder="example.com" />
|
<input type="text" value={editForm.linkedin} onChange={setEditField('linkedin')} placeholder="linkedin.com/in/username" style={modalInputStyle} />
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>LinkedIn</label>
|
|
||||||
<input value={editForm.linkedin} onChange={setEditField('linkedin')} placeholder="linkedin.com/in/username" />
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Instagram</label>
|
|
||||||
<input value={editForm.instagram} onChange={setEditField('instagram')} placeholder="instagram.com/username" />
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>X / Twitter</label>
|
|
||||||
<input value={editForm.twitter} onChange={setEditField('twitter')} placeholder="x.com/username" />
|
|
||||||
</div>
|
</div>
|
||||||
{editError && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>{editError}</div>}
|
{editError && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>{editError}</div>}
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 6 }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 6 }}>
|
||||||
<button type="button" className="btn btn-outline" onClick={() => setEditOpen(false)} disabled={savingProfile}>
|
<button type="button" className="btn btn-outline" onClick={() => setEditOpen(false)} disabled={savingProfile} style={modalButtonStyle}>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" className="btn btn-primary" disabled={savingProfile}>
|
<button type="submit" className="btn btn-outline" disabled={savingProfile} style={modalButtonStyle}>
|
||||||
{savingProfile ? 'Saving...' : 'Save Changes'}
|
{savingProfile ? 'Saving...' : 'Save Changes'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ const buildRevisionItemDescription = (revision) => {
|
|||||||
return `${projectName} • ${taskTitle} • Revision ${versionLabel}`;
|
return `${projectName} • ${taskTitle} • Revision ${versionLabel}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getRevisionChargeQuantity = (revision) => {
|
||||||
|
const version = Number(revision?.version_number || 0);
|
||||||
|
// Incremental billing: R01 is free, each uninvoiced revision from R02+ is one charge unit.
|
||||||
|
return version >= 2 ? 1 : 0;
|
||||||
|
};
|
||||||
|
|
||||||
export default function CreateInvoice() {
|
export default function CreateInvoice() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { currentUser } = useAuth();
|
const { currentUser } = useAuth();
|
||||||
@@ -90,23 +96,22 @@ export default function CreateInvoice() {
|
|||||||
setInvoiceEmail(recipients[0]?.email || companies.find(c => c.id === selectedCompanyId)?.contact_email || '');
|
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 } = await supabase
|
||||||
supabase
|
|
||||||
.from('tasks')
|
.from('tasks')
|
||||||
.select('*, project:projects(name), submissions(service_type, type, version_number)')
|
.select('*, project:projects(name), submissions(service_type, type, version_number)')
|
||||||
.in('project_id', projectIds)
|
.in('project_id', projectIds)
|
||||||
.eq('invoiced', false)
|
.eq('invoiced', false)
|
||||||
.eq('status', 'client_approved'),
|
.eq('status', 'client_approved');
|
||||||
supabase
|
const approvedTaskIds = (tasks || []).map((t) => t.id).filter(Boolean);
|
||||||
|
const { data: revisions } = approvedTaskIds.length > 0
|
||||||
|
? await supabase
|
||||||
.from('submissions')
|
.from('submissions')
|
||||||
.select('*, task:tasks(id, title, project:projects(name), submissions(service_type, type))')
|
.select('*, task:tasks(id, title, project:projects(name), submissions(service_type, type))')
|
||||||
.eq('type', 'revision')
|
.eq('type', 'revision')
|
||||||
.or('revision_type.eq.client_revision,revision_type.is.null')
|
.or('revision_type.eq.client_revision,revision_type.is.null')
|
||||||
.eq('invoiced', false)
|
.eq('invoiced', false)
|
||||||
.in('task_id',
|
.in('task_id', approvedTaskIds)
|
||||||
(await supabase.from('tasks').select('id').in('project_id', projectIds)).data?.map(t => t.id) || []
|
: { data: [] };
|
||||||
),
|
|
||||||
]);
|
|
||||||
const tasksWithService = (tasks || []).map(t => {
|
const tasksWithService = (tasks || []).map(t => {
|
||||||
const initial = (t.submissions || []).find(s => s.type === 'initial') || (t.submissions || [])[0];
|
const initial = (t.submissions || []).find(s => s.type === 'initial') || (t.submissions || [])[0];
|
||||||
return { ...t, service_type: initial?.service_type || t.title };
|
return { ...t, service_type: initial?.service_type || t.title };
|
||||||
@@ -141,11 +146,14 @@ export default function CreateInvoice() {
|
|||||||
const serviceLabel = getRevisionServiceType(revision);
|
const serviceLabel = getRevisionServiceType(revision);
|
||||||
const description = buildRevisionItemDescription(revision);
|
const description = buildRevisionItemDescription(revision);
|
||||||
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');
|
||||||
|
const revisionChargeQty = getRevisionChargeQuantity(revision);
|
||||||
|
const quantity = revisionChargeQty > 0 ? revisionChargeQty : 1;
|
||||||
|
const unitPrice = revisionChargeQty > 0 ? (price?.price || '') : 0;
|
||||||
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, revision.task_id, revision.id)];
|
return [newItem(description, unitPrice, quantity, revision.task_id, revision.id)];
|
||||||
}
|
}
|
||||||
return [...prev, newItem(description, price?.price || '', 1, revision.task_id, revision.id)];
|
return [...prev, newItem(description, unitPrice, quantity, revision.task_id, revision.id)];
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -319,6 +319,60 @@ function MiniCalendar({ items = [] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TasksInProgressCard({ tasks = [] }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { sortKey, sortDir, toggle, sort } = useSortable('task');
|
||||||
|
const [showAll, setShowAll] = useState(false);
|
||||||
|
const rows = sort(tasks.slice(0, 10), (t, key) => {
|
||||||
|
if (key === 'task') return t.title || '';
|
||||||
|
if (key === 'assigned_to') return t.assigned_name || '';
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
const visibleRows = showAll ? rows : rows.slice(0, 5);
|
||||||
|
return (
|
||||||
|
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<div style={{ marginBottom: rows.length > 0 ? 14 : 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Tasks In Progress</span>
|
||||||
|
{rows.length > 5 && (
|
||||||
|
<button type="button" className="dashboard-inline-link" style={{ fontSize: 11, fontWeight: 500, letterSpacing: 0.4, textTransform: 'uppercase' }} onClick={() => setShowAll((v) => !v)}>
|
||||||
|
{showAll ? 'Show less' : 'Show all'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No tasks in progress</div>
|
||||||
|
) : (
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||||
|
<colgroup>
|
||||||
|
<col style={{ width: '55%' }} />
|
||||||
|
<col style={{ width: '45%' }} />
|
||||||
|
</colgroup>
|
||||||
|
<thead style={{ background: 'transparent' }}>
|
||||||
|
<tr style={{ background: 'transparent' }}>
|
||||||
|
<SortTh col="task" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'left', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 5px', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Task</SortTh>
|
||||||
|
<SortTh col="assigned_to" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'right', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 5px 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Assigned To</SortTh>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{visibleRows.map((t) => (
|
||||||
|
<tr key={t.id} style={{ background: 'transparent' }}>
|
||||||
|
<td style={{ fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', padding: '5px', border: 'none', background: 'transparent', textAlign: 'left' }}>
|
||||||
|
<button type="button" className="dashboard-inline-link" onClick={() => navigate('/requests/' + t.id)}>{t.title}</button>
|
||||||
|
</td>
|
||||||
|
<td style={{ fontSize: 12, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', padding: '5px', border: 'none', background: 'transparent', textAlign: 'right' }}>
|
||||||
|
{t.assigned_to
|
||||||
|
? <button type="button" className="dashboard-inline-link" onClick={() => navigate(profilePath(t.assigned_to, t.assignee_role))}>{t.assigned_name || 'Profile'}</button>
|
||||||
|
: (t.assigned_name || '—')}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function HotItemsCard({ submissions, tasks }) {
|
function HotItemsCard({ submissions, tasks }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { sortKey, sortDir, toggle, sort } = useSortable('deadline');
|
const { sortKey, sortDir, toggle, sort } = useSortable('deadline');
|
||||||
@@ -344,10 +398,10 @@ function HotItemsCard({ submissions, tasks }) {
|
|||||||
return (
|
return (
|
||||||
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column' }}>
|
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column' }}>
|
||||||
<div style={{ marginBottom: hotItems.length > 0 ? 14 : 0, flexShrink: 0 }}>
|
<div style={{ marginBottom: hotItems.length > 0 ? 14 : 0, flexShrink: 0 }}>
|
||||||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Hot Items</span>
|
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Hot Tasks</span>
|
||||||
</div>
|
</div>
|
||||||
{hotItems.length === 0 ? (
|
{hotItems.length === 0 ? (
|
||||||
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No hot items</div>
|
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No hot tasks</div>
|
||||||
) : (
|
) : (
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||||
<colgroup>
|
<colgroup>
|
||||||
@@ -467,7 +521,13 @@ function ClientHighlightCard({ highlights }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TeamPerformanceCard({ tasks }) {
|
function TeamPerformanceCard({ tasks, profiles }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const profileMap = useMemo(() => {
|
||||||
|
const m = new Map();
|
||||||
|
(profiles || []).forEach(p => m.set(p.id, p));
|
||||||
|
return m;
|
||||||
|
}, [profiles]);
|
||||||
const doneStatuses = ['client_approved', 'invoiced', 'paid'];
|
const doneStatuses = ['client_approved', 'invoiced', 'paid'];
|
||||||
const allMonthOpts = useMemo(() => {
|
const allMonthOpts = useMemo(() => {
|
||||||
const opts = [];
|
const opts = [];
|
||||||
@@ -495,7 +555,7 @@ function TeamPerformanceCard({ tasks }) {
|
|||||||
const d = new Date(t.completed_at);
|
const d = new Date(t.completed_at);
|
||||||
if (d.getFullYear() !== year || d.getMonth() + 1 !== month) return;
|
if (d.getFullYear() !== year || d.getMonth() + 1 !== month) return;
|
||||||
if (!t.assigned_name) return;
|
if (!t.assigned_name) return;
|
||||||
const entry = map.get(t.assigned_name) || { name: t.assigned_name, newCount: 0, revCount: 0 };
|
const entry = map.get(t.assigned_name) || { name: t.assigned_name, id: t.assigned_to || null, newCount: 0, revCount: 0 };
|
||||||
if ((t.current_version || 0) === 0) entry.newCount += 1;
|
if ((t.current_version || 0) === 0) entry.newCount += 1;
|
||||||
else entry.revCount += 1;
|
else entry.revCount += 1;
|
||||||
map.set(t.assigned_name, entry);
|
map.set(t.assigned_name, entry);
|
||||||
@@ -524,10 +584,10 @@ function TeamPerformanceCard({ tasks }) {
|
|||||||
const tone = iconTone(p.name);
|
const tone = iconTone(p.name);
|
||||||
return (
|
return (
|
||||||
<div key={p.name} style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: i > 0 ? 10 : 0 }}>
|
<div key={p.name} style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: i > 0 ? 10 : 0 }}>
|
||||||
<Avatar name={p.name} />
|
{(() => { const prof = p.id ? profileMap.get(p.id) : null; const tone = iconTone(p.name); return prof?.avatar_url ? <div style={{ width: 27, height: 27, borderRadius: '50%', flexShrink: 0, overflow: 'hidden' }}><img src={prof.avatar_url} alt={p.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /></div> : <Avatar name={p.name} />; })()}
|
||||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
<span style={{ fontSize: 13, color: 'var(--text-primary)', fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
|
{p.id ? <button type="button" className="dashboard-inline-link" style={{ fontSize: 13, fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} onClick={() => navigate(`/profile/${p.id}`)}>{p.name}</button> : <span style={{ fontSize: 13, color: 'var(--text-primary)', fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>}
|
||||||
<span style={{ fontSize: 11, color: 'var(--text-muted)', flexShrink: 0, marginLeft: 8 }}>{p.newCount} new · {p.revCount} revision</span>
|
<span style={{ fontSize: 11, color: 'var(--text-muted)', flexShrink: 0, marginLeft: 8 }}>{p.newCount} new · {p.revCount} revision</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
@@ -562,6 +622,7 @@ export default function TeamDashboard() {
|
|||||||
const [clientProfiles, setClientProfiles] = useState(() => cached?.clientProfiles || []);
|
const [clientProfiles, setClientProfiles] = useState(() => cached?.clientProfiles || []);
|
||||||
const [companyMemberships, setCompanyMemberships] = useState(() => cached?.companyMemberships || []);
|
const [companyMemberships, setCompanyMemberships] = useState(() => cached?.companyMemberships || []);
|
||||||
const [activityLog, setActivityLog] = useState(() => cached?.activityLog || []);
|
const [activityLog, setActivityLog] = useState(() => cached?.activityLog || []);
|
||||||
|
const [allProfiles, setAllProfiles] = useState(() => cached?.allProfiles || []);
|
||||||
const [teamInvoices, setTeamInvoices] = useState(() => cached?.teamInvoices || []);
|
const [teamInvoices, setTeamInvoices] = useState(() => cached?.teamInvoices || []);
|
||||||
const [teamExpenses, setTeamExpenses] = useState([]);
|
const [teamExpenses, setTeamExpenses] = useState([]);
|
||||||
const [loading, setLoading] = useState(!cached);
|
const [loading, setLoading] = useState(!cached);
|
||||||
@@ -586,7 +647,7 @@ export default function TeamDashboard() {
|
|||||||
supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_name, assigned_to, completed_at').gte('submitted_at', cutoffStr).order('submitted_at', { ascending: false }),
|
supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_name, assigned_to, completed_at').gte('submitted_at', cutoffStr).order('submitted_at', { ascending: false }),
|
||||||
supabase.from('projects').select('id, name, status, company_id'),
|
supabase.from('projects').select('id, name, status, company_id'),
|
||||||
supabase.from('submissions').select('task_id, version_number, deadline, type, submitted_by, submitted_by_name, is_hot, delivery:deliveries(sent_by, sent_at)').gte('submitted_at', cutoffStr).order('version_number', { ascending: false }),
|
supabase.from('submissions').select('task_id, version_number, deadline, type, submitted_by, submitted_by_name, is_hot, delivery:deliveries(sent_by, sent_at)').gte('submitted_at', cutoffStr).order('version_number', { ascending: false }),
|
||||||
supabase.from('profiles').select('id, role, name, email, company_id, brand_book_rate'),
|
supabase.from('profiles').select('id, role, name, email, company_id, brand_book_rate, avatar_url'),
|
||||||
supabase.from('activity_log').select('id, created_at, actor_id, actor_name, action, task_id, task_title, project_name, project_id').order('created_at', { ascending: false }).limit(20),
|
supabase.from('activity_log').select('id, created_at, actor_id, actor_name, action, task_id, task_title, project_name, project_id').order('created_at', { ascending: false }).limit(20),
|
||||||
supabase.from('companies').select('id, name').order('name'),
|
supabase.from('companies').select('id, name').order('name'),
|
||||||
supabase.from('company_members').select('company_id, profile_id'),
|
supabase.from('company_members').select('company_id, profile_id'),
|
||||||
@@ -613,6 +674,7 @@ export default function TeamDashboard() {
|
|||||||
setProjects(p || []);
|
setProjects(p || []);
|
||||||
setSubmissions(subsWithRole);
|
setSubmissions(subsWithRole);
|
||||||
setClientProfiles(clients);
|
setClientProfiles(clients);
|
||||||
|
setAllProfiles(profiles || []);
|
||||||
setAllCompanies(cos || []);
|
setAllCompanies(cos || []);
|
||||||
setCompanyMemberships(memRows || []);
|
setCompanyMemberships(memRows || []);
|
||||||
setActivityLog(activity || []);
|
setActivityLog(activity || []);
|
||||||
@@ -662,6 +724,7 @@ export default function TeamDashboard() {
|
|||||||
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 doneStatuses = ['client_approved', 'invoiced', 'paid'];
|
const doneStatuses = ['client_approved', 'invoiced', 'paid'];
|
||||||
|
const inProgressTasks = tasks.filter(t => t.status === 'in_progress');
|
||||||
const activeTasks = tasks.filter(t => !doneStatuses.includes(t.status));
|
const activeTasks = tasks.filter(t => !doneStatuses.includes(t.status));
|
||||||
const activeProjects = projects.filter(p => p.status !== 'completed' && p.status !== 'cancelled');
|
const activeProjects = projects.filter(p => p.status !== 'completed' && p.status !== 'cancelled');
|
||||||
const hotTaskIds = new Set((submissions || []).filter(s => s.is_hot).map(s => s.task_id));
|
const hotTaskIds = new Set((submissions || []).filter(s => s.is_hot).map(s => s.task_id));
|
||||||
@@ -696,13 +759,14 @@ export default function TeamDashboard() {
|
|||||||
<DashStatCard label="Net Profit" value={fmtMoney(dashRevenue - dashExpensesTotal)} sub={`${fmtMoney(dashExpensesTotal)} expenses`} iconBg="rgba(74,222,128,0.15)" iconColor="#4ade80" iconPath={DASH_ICONS.profit} />
|
<DashStatCard label="Net Profit" value={fmtMoney(dashRevenue - dashExpensesTotal)} sub={`${fmtMoney(dashExpensesTotal)} expenses`} iconBg="rgba(74,222,128,0.15)" iconColor="#4ade80" iconPath={DASH_ICONS.profit} />
|
||||||
<DashStatCard label="Revenue" value={fmtMoney(dashRevenue)} sub={`${fmtMoney(dashOutstanding)} outstanding`} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconPath={DASH_ICONS.revenue} chartData={revenueByMonth} />
|
<DashStatCard label="Revenue" value={fmtMoney(dashRevenue)} sub={`${fmtMoney(dashOutstanding)} outstanding`} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconPath={DASH_ICONS.revenue} chartData={revenueByMonth} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 280px', gap: 24, marginTop: 24 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 280px', gap: 24, marginTop: 24 }}>
|
||||||
<ActivityFeed events={activityEvents} />
|
<ActivityFeed events={activityEvents} />
|
||||||
|
<TasksInProgressCard tasks={inProgressTasks} />
|
||||||
<MiniCalendar items={calendarItems} />
|
<MiniCalendar items={calendarItems} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginTop: 24 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginTop: 24 }}>
|
||||||
<HotItemsCard submissions={submissions} tasks={tasks} />
|
<HotItemsCard submissions={submissions} tasks={tasks} />
|
||||||
<TeamPerformanceCard tasks={tasks} />
|
<TeamPerformanceCard tasks={tasks} profiles={allProfiles} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: 24 }}>
|
<div style={{ marginTop: 24 }}>
|
||||||
<ClientHighlightCard highlights={teamHighlights} />
|
<ClientHighlightCard highlights={teamHighlights} />
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
|
||||||
|
|
||||||
|
const FBQ_URL = Deno.env.get('FBQ_URL') ?? '';
|
||||||
|
const FBQ_TOKEN = Deno.env.get('FBQ_TOKEN') ?? '';
|
||||||
|
const SUPABASE_URL = Deno.env.get('SUPABASE_URL') ?? '';
|
||||||
|
const SUPABASE_SERVICE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '';
|
||||||
|
const SOURCE = 'srv';
|
||||||
|
const fbqH = { Authorization: `Bearer ${FBQ_TOKEN}` };
|
||||||
|
|
||||||
|
function safe(v: string) {
|
||||||
|
return String(v||'').trim().replace(/[\\/:*?"<>|#%{}^~[\]`]+/g,'-').replace(/\s+/g,' ').replace(/^-+|-+$/g,'');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 200 = created new, 409 = already existed
|
||||||
|
async function mkdir(path: string): Promise<boolean> {
|
||||||
|
const p = path.endsWith('/') ? path : path + '/';
|
||||||
|
const r = await fetch(`${FBQ_URL}/api/resources?source=${SOURCE}&path=${encodeURIComponent(p)}&isDir=true`, {
|
||||||
|
method: 'POST', headers: fbqH,
|
||||||
|
});
|
||||||
|
return r.status === 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upload(path: string, data: ArrayBuffer, ct: string) {
|
||||||
|
await fetch(`${FBQ_URL}/api/resources?source=${SOURCE}&path=${encodeURIComponent(path)}`, {
|
||||||
|
method: 'POST', headers: { ...fbqH, 'Content-Type': ct }, body: data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mime(name: string) {
|
||||||
|
const e = name.split('.').pop()?.toLowerCase() ?? '';
|
||||||
|
const m: Record<string,string> = { pdf:'application/pdf', jpg:'image/jpeg', jpeg:'image/jpeg', png:'image/png', gif:'image/gif', webp:'image/webp', svg:'image/svg+xml', ai:'application/postscript', eps:'application/postscript', zip:'application/zip', mp4:'video/mp4', mov:'video/quicktime' };
|
||||||
|
return m[e] ?? 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chunkAll<T>(items: T[], fn: (item: T) => Promise<void>, size = 8) {
|
||||||
|
for (let i = 0; i < items.length; i += size) {
|
||||||
|
await Promise.all(items.slice(i, i + size).map(fn));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.serve(async () => {
|
||||||
|
const sb = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY);
|
||||||
|
const log: string[] = [];
|
||||||
|
let created = 0, skipped = 0, fileCount = 0, errors = 0;
|
||||||
|
|
||||||
|
// fetch all data upfront in parallel
|
||||||
|
const [usersRes, companiesRes, tasksRes] = await Promise.all([
|
||||||
|
sb.from('profiles').select('name,role').in('role',['team','external']),
|
||||||
|
sb.from('companies').select('name'),
|
||||||
|
sb.from('tasks').select('id,title,projects!project_id(name,companies!company_id(name))'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
log.push(`db: ${usersRes.data?.length} users, ${companiesRes.data?.length} companies, ${tasksRes.data?.length ?? 0} tasks`);
|
||||||
|
if (tasksRes.error) log.push(`task err: ${tasksRes.error.message}`);
|
||||||
|
|
||||||
|
// 1. user folders in parallel
|
||||||
|
await mkdir('/Team');
|
||||||
|
await chunkAll(usersRes.data ?? [], async (u) => {
|
||||||
|
if (!u.name) return;
|
||||||
|
const isNew = await mkdir(`/Team/${safe(u.name)}`);
|
||||||
|
if (isNew) { log.push(`+ /Team/${safe(u.name)}`); created++; } else skipped++;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. company folders in parallel
|
||||||
|
await mkdir('/Clients');
|
||||||
|
await chunkAll(companiesRes.data ?? [], async (c) => {
|
||||||
|
if (!c.name) return;
|
||||||
|
const isNew = await mkdir(`/Clients/${safe(c.name)}`);
|
||||||
|
if (isNew) { log.push(`+ /Clients/${safe(c.name)}`); created++; } else skipped++;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. tasks — project structure + task folder in parallel, then files sequentially for new ones
|
||||||
|
const newTasks: { task: any; base: string; co: string; pr: string }[] = [];
|
||||||
|
|
||||||
|
await chunkAll(tasksRes.data ?? [], async (task: any) => {
|
||||||
|
const coName = task?.projects?.companies?.name;
|
||||||
|
const prjName = task?.projects?.name;
|
||||||
|
if (!coName || !prjName || !task.title) return;
|
||||||
|
|
||||||
|
const co = safe(coName);
|
||||||
|
const pr = safe(prjName);
|
||||||
|
const tk = safe(task.title);
|
||||||
|
const base = `/Clients/${co}/Projects/${pr}/${tk}`;
|
||||||
|
|
||||||
|
// ensure project structure (idempotent)
|
||||||
|
await Promise.all([
|
||||||
|
mkdir(`/Clients/${co}/Projects`),
|
||||||
|
mkdir(`/Clients/${co}/Projects/${pr}`),
|
||||||
|
mkdir(`/Clients/${co}/Projects/${pr}/00 Project Files`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const taskIsNew = await mkdir(base);
|
||||||
|
await Promise.all([mkdir(`${base}/Request Info`), mkdir(`${base}/Old Book`)]);
|
||||||
|
|
||||||
|
if (taskIsNew) { newTasks.push({ task, base, co, pr }); created++; }
|
||||||
|
else skipped++;
|
||||||
|
}, 6);
|
||||||
|
|
||||||
|
log.push(`new task folders: ${newTasks.length}`);
|
||||||
|
newTasks.forEach(({ base }) => log.push(`+ ${base}`));
|
||||||
|
|
||||||
|
// 4. upload files for newly created task folders (sequential to avoid memory spikes)
|
||||||
|
for (const { task, base } of newTasks) {
|
||||||
|
const { data: subs } = await sb.from('submissions').select('id,version_number').eq('task_id', task.id);
|
||||||
|
for (const sub of subs ?? []) {
|
||||||
|
const { data: subFiles } = await sb.from('submission_files').select('name,storage_path').eq('submission_id', sub.id);
|
||||||
|
if (!subFiles?.length) continue;
|
||||||
|
const rev = `R${String(sub.version_number).padStart(2,'0')}`;
|
||||||
|
await mkdir(`${base}/Request Info/${rev}`);
|
||||||
|
for (const f of subFiles) {
|
||||||
|
try {
|
||||||
|
const { data: blob, error } = await sb.storage.from('submissions').download(f.storage_path);
|
||||||
|
if (error || !blob) { errors++; continue; }
|
||||||
|
await upload(`${base}/Request Info/${rev}/${f.name}`, await blob.arrayBuffer(), mime(f.name));
|
||||||
|
fileCount++;
|
||||||
|
} catch { errors++; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ created, skipped, files: fileCount, errors, log }, null, 2), {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
|
||||||
|
|
||||||
|
const FBQ_URL = Deno.env.get('FBQ_URL') ?? '';
|
||||||
|
const FBQ_TOKEN = Deno.env.get('FBQ_TOKEN') ?? '';
|
||||||
|
const SUPABASE_URL = Deno.env.get('SUPABASE_URL') ?? '';
|
||||||
|
const SUPABASE_SERVICE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '';
|
||||||
|
const SOURCE = 'srv';
|
||||||
|
|
||||||
|
function cors(origin: string) {
|
||||||
|
return {
|
||||||
|
'Access-Control-Allow-Origin': origin,
|
||||||
|
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Authorization, Content-Type, X-Operation, X-Path, X-Files',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function json(body: unknown, status = 200, origin = '*') {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { ...cors(origin), 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePath(path: string) {
|
||||||
|
const raw = String(path || '/').trim();
|
||||||
|
const parts = raw.split('/').filter(Boolean);
|
||||||
|
return '/' + parts.join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeSegment(value: string, fallback = '') {
|
||||||
|
const cleaned = String(value || '')
|
||||||
|
.trim()
|
||||||
|
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
return cleaned || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExternalProject = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
companyName: string;
|
||||||
|
virtualName: string;
|
||||||
|
virtualRoot: string;
|
||||||
|
realRoot: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Access =
|
||||||
|
| { role: 'team'; roots: string[] }
|
||||||
|
| { role: 'client'; roots: string[] }
|
||||||
|
| { role: 'external'; roots: string[]; projects: ExternalProject[] };
|
||||||
|
|
||||||
|
function pathAllowed(path: string, access: Access): boolean {
|
||||||
|
if (access.roots.includes('*')) return true;
|
||||||
|
const p = normalizePath(path).replace(/\/+$/, '') || '/';
|
||||||
|
if (access.role === 'client' && p === '/Clients' && access.roots.some(r => r.startsWith('/Clients/'))) return true;
|
||||||
|
if (access.role === 'external' && p === '/') return true;
|
||||||
|
return access.roots.some(r => p === r || p.startsWith(r.replace(/\/$/, '') + '/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getExternalProjects(sb: ReturnType<typeof createClient>, userId: string): Promise<ExternalProject[]> {
|
||||||
|
const { data, error } = await sb
|
||||||
|
.from('project_members')
|
||||||
|
.select('project:projects(id, name, company:companies(name))')
|
||||||
|
.eq('profile_id', userId);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
const seen = new Map<string, number>();
|
||||||
|
return (data ?? [])
|
||||||
|
.map((row: any) => {
|
||||||
|
const projectId = row?.project?.id;
|
||||||
|
const projectName = row?.project?.name;
|
||||||
|
const companyName = row?.project?.company?.name;
|
||||||
|
if (!projectId || !projectName || !companyName) return null;
|
||||||
|
const baseName = safeSegment(projectName, projectId);
|
||||||
|
const count = seen.get(baseName) ?? 0;
|
||||||
|
seen.set(baseName, count + 1);
|
||||||
|
const virtualName = count === 0 ? baseName : `${baseName} (${safeSegment(companyName, projectId)})`;
|
||||||
|
const companyFolder = safeSegment(companyName, companyName);
|
||||||
|
const projectFolder = safeSegment(projectName, projectId);
|
||||||
|
return {
|
||||||
|
id: projectId,
|
||||||
|
name: projectName,
|
||||||
|
companyName,
|
||||||
|
virtualName,
|
||||||
|
virtualRoot: `/${virtualName}`,
|
||||||
|
realRoot: `/Clients/${companyFolder}/Projects/${projectFolder}`,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => a!.virtualName.localeCompare(b!.virtualName)) as ExternalProject[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUserAccess(userId: string): Promise<Access | null> {
|
||||||
|
const sb = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY);
|
||||||
|
const { data: profile } = await sb.from('profiles').select('role, company_id').eq('id', userId).single();
|
||||||
|
if (!profile) return null;
|
||||||
|
if (profile.role === 'team') return { role: 'team', roots: ['*'] };
|
||||||
|
if (profile.role === 'external') {
|
||||||
|
const projects = await getExternalProjects(sb, userId);
|
||||||
|
return {
|
||||||
|
role: 'external',
|
||||||
|
roots: ['/Team', ...projects.map(project => project.virtualRoot)],
|
||||||
|
projects,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (profile.role === 'client') {
|
||||||
|
const roots: string[] = [];
|
||||||
|
if (profile.company_id) {
|
||||||
|
const { data: co } = await sb.from('companies').select('name').eq('id', profile.company_id).single();
|
||||||
|
if (co?.name) roots.push(`/Clients/${co.name}`);
|
||||||
|
}
|
||||||
|
const { data: memberships } = await sb.from('company_members').select('company:companies(name)').eq('profile_id', userId);
|
||||||
|
(memberships ?? []).forEach((m: any) => {
|
||||||
|
const n = m?.company?.name;
|
||||||
|
if (n && !roots.includes(`/Clients/${n}`)) roots.push(`/Clients/${n}`);
|
||||||
|
});
|
||||||
|
return roots.length ? { role: 'client', roots } : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listVirtualClientsRoot(roots: string[]) {
|
||||||
|
const folders = roots
|
||||||
|
.filter((root) => root.startsWith('/Clients/'))
|
||||||
|
.map((root) => root.replace(/\/+$/, ''))
|
||||||
|
.map((root) => {
|
||||||
|
const name = root.split('/').filter(Boolean).pop() ?? '';
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
filename: name,
|
||||||
|
path: root,
|
||||||
|
type: 'directory',
|
||||||
|
isDir: true,
|
||||||
|
size: 0,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
return { folders, files: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function listVirtualExternalRoot(projects: ExternalProject[]) {
|
||||||
|
return {
|
||||||
|
folders: [
|
||||||
|
{ name: 'Team', filename: 'Team', path: '/Team', type: 'directory', isDir: true, size: 0 },
|
||||||
|
...projects.map((project) => ({
|
||||||
|
name: project.virtualName,
|
||||||
|
filename: project.virtualName,
|
||||||
|
path: project.virtualRoot,
|
||||||
|
type: 'directory',
|
||||||
|
isDir: true,
|
||||||
|
size: 0,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
files: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePath(path: string, access: Access) {
|
||||||
|
const normalized = normalizePath(path);
|
||||||
|
if (access.role !== 'external') return normalized;
|
||||||
|
if (normalized === '/') return normalized;
|
||||||
|
if (normalized === '/Team' || normalized.startsWith('/Team/')) {
|
||||||
|
return normalized.replace(/^\/Team(?=\/|$)/, '/Team');
|
||||||
|
}
|
||||||
|
const project = access.projects.find((entry) => normalized === entry.virtualRoot || normalized.startsWith(entry.virtualRoot + '/'));
|
||||||
|
if (!project) return normalized;
|
||||||
|
const suffix = normalized.slice(project.virtualRoot.length);
|
||||||
|
return `${project.realRoot}${suffix}` || project.realRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fbqHeaders = { Authorization: `Bearer ${FBQ_TOKEN}` };
|
||||||
|
|
||||||
|
Deno.serve(async (req) => {
|
||||||
|
const origin = req.headers.get('origin') ?? '*';
|
||||||
|
if (req.method === 'OPTIONS') return new Response(null, { headers: cors(origin) });
|
||||||
|
|
||||||
|
const authHeader = req.headers.get('authorization') ?? '';
|
||||||
|
if (!authHeader.startsWith('Bearer ')) return json({ error: 'Unauthorized' }, 401, origin);
|
||||||
|
|
||||||
|
const sb = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY);
|
||||||
|
const { data: { user }, error } = await sb.auth.getUser(authHeader.slice(7));
|
||||||
|
if (error || !user) return json({ error: 'Unauthorized' }, 401, origin);
|
||||||
|
|
||||||
|
const access = await getUserAccess(user.id);
|
||||||
|
if (!access) return json({ error: 'Forbidden' }, 403, origin);
|
||||||
|
|
||||||
|
const op = req.headers.get('x-operation') ?? '';
|
||||||
|
const path = req.headers.get('x-path') ?? '/';
|
||||||
|
|
||||||
|
if (!pathAllowed(path, access)) return json({ error: 'Forbidden' }, 403, origin);
|
||||||
|
const resolvedPath = resolvePath(path, access);
|
||||||
|
|
||||||
|
if (op === 'list') {
|
||||||
|
if (access.role === 'client' && path.replace(/\/+$/, '') === '/Clients' && !access.roots.includes('*')) {
|
||||||
|
return json(await listVirtualClientsRoot(access.roots), 200, origin);
|
||||||
|
}
|
||||||
|
if (access.role === 'external' && normalizePath(path) === '/') {
|
||||||
|
return json(listVirtualExternalRoot(access.projects), 200, origin);
|
||||||
|
}
|
||||||
|
const res = await fetch(`${FBQ_URL}/api/resources?source=${SOURCE}&path=${encodeURIComponent(resolvedPath)}`, { headers: fbqHeaders });
|
||||||
|
const body = await res.text();
|
||||||
|
return new Response(body, { status: res.status, headers: { ...cors(origin), 'Content-Type': 'application/json' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (op === 'archive') {
|
||||||
|
const files = JSON.parse(req.headers.get('x-files') ?? '[]') as string[];
|
||||||
|
const params = new URLSearchParams({ source: SOURCE });
|
||||||
|
files.map(f => resolvePath(f, access)).forEach(f => params.append('files[]', f));
|
||||||
|
const res = await fetch(`${FBQ_URL}/api/archive?${params.toString()}`, { headers: fbqHeaders });
|
||||||
|
const cd = `attachment; filename="download.zip"`;
|
||||||
|
return new Response(res.body, { status: res.status, headers: { ...cors(origin), 'Content-Type': 'application/zip', 'Content-Disposition': cd } });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (op === 'download') {
|
||||||
|
const res = await fetch(`${FBQ_URL}/api/raw?source=${SOURCE}&path=${encodeURIComponent(resolvedPath)}`, { headers: fbqHeaders });
|
||||||
|
const ct = res.headers.get('content-type') ?? 'application/octet-stream';
|
||||||
|
const cd = res.headers.get('content-disposition') ?? `attachment; filename="${resolvedPath.split('/').pop()}"`;
|
||||||
|
return new Response(res.body, { status: res.status, headers: { ...cors(origin), 'Content-Type': ct, 'Content-Disposition': cd } });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (op === 'upload') {
|
||||||
|
const body = await req.arrayBuffer();
|
||||||
|
const ct = req.headers.get('content-type') ?? '';
|
||||||
|
const res = await fetch(`${FBQ_URL}/api/resources?source=${SOURCE}&path=${encodeURIComponent(resolvedPath)}`, {
|
||||||
|
method: 'POST', headers: { ...fbqHeaders, 'Content-Type': ct }, body,
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
return new Response(text, { status: res.status, headers: { ...cors(origin), 'Content-Type': 'application/json' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (op === 'delete') {
|
||||||
|
const files = JSON.parse(req.headers.get('x-files') ?? '[]') as string[];
|
||||||
|
await Promise.all(files.map(f =>
|
||||||
|
fetch(`${FBQ_URL}/api/resources?source=${SOURCE}&path=${encodeURIComponent(resolvePath(f, access))}`, { method: 'DELETE', headers: fbqHeaders })
|
||||||
|
));
|
||||||
|
return json({ ok: true }, 200, origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (op === 'copy' || op === 'move') {
|
||||||
|
const srcPaths = JSON.parse(req.headers.get('x-files') ?? '[]') as string[];
|
||||||
|
const destDir = resolvedPath.replace(/\/+$/, '');
|
||||||
|
const items = srcPaths.map(src => {
|
||||||
|
const resolved = resolvePath(src, access);
|
||||||
|
const name = resolved.split('/').filter(Boolean).pop() ?? '';
|
||||||
|
return { fromSource: SOURCE, fromPath: resolved, toSource: SOURCE, toPath: `${destDir}/${name}` };
|
||||||
|
});
|
||||||
|
const res = await fetch(`${FBQ_URL}/api/resources?source=${SOURCE}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { ...fbqHeaders, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: op, items, overwrite: false }),
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
return new Response(text, { status: res.status, headers: { ...cors(origin), 'Content-Type': 'application/json' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (op === 'mkdir') {
|
||||||
|
const dirPath = resolvedPath.endsWith('/') ? resolvedPath : resolvedPath + '/';
|
||||||
|
const res = await fetch(`${FBQ_URL}/api/resources?source=${SOURCE}&path=${encodeURIComponent(dirPath)}&isDir=true`, {
|
||||||
|
method: 'POST', headers: fbqHeaders,
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
return new Response(text, { status: res.status, headers: { ...cors(origin), 'Content-Type': 'application/json' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ error: 'Unknown operation' }, 400, origin);
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
alter table public.profiles
|
||||||
|
add column if not exists avatar_url text;
|
||||||
|
|
||||||
|
insert into storage.buckets (id, name, public)
|
||||||
|
select 'avatars', 'avatars', true
|
||||||
|
where not exists (select 1 from storage.buckets where id = 'avatars');
|
||||||
|
|
||||||
|
do $$
|
||||||
|
begin
|
||||||
|
if not exists (select 1 from pg_policies where policyname = 'Avatar images are publicly accessible' and tablename = 'objects') then
|
||||||
|
execute 'create policy "Avatar images are publicly accessible" on storage.objects for select using (bucket_id = ''avatars'')';
|
||||||
|
end if;
|
||||||
|
if not exists (select 1 from pg_policies where policyname = 'Users can upload their own avatar' and tablename = 'objects') then
|
||||||
|
execute 'create policy "Users can upload their own avatar" on storage.objects for insert with check (bucket_id = ''avatars'' and auth.uid()::text = (storage.foldername(name))[1])';
|
||||||
|
end if;
|
||||||
|
if not exists (select 1 from pg_policies where policyname = 'Users can update their own avatar' and tablename = 'objects') then
|
||||||
|
execute 'create policy "Users can update their own avatar" on storage.objects for update using (bucket_id = ''avatars'' and auth.uid()::text = (storage.foldername(name))[1])';
|
||||||
|
end if;
|
||||||
|
end $$;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
alter table public.profiles
|
||||||
|
add column if not exists website text,
|
||||||
|
add column if not exists linkedin text,
|
||||||
|
add column if not exists instagram text,
|
||||||
|
add column if not exists twitter text;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
alter table public.profiles
|
||||||
|
drop column if exists instagram,
|
||||||
|
drop column if exists twitter;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
alter table public.profiles
|
||||||
|
drop column if exists title;
|
||||||