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>
This commit is contained in:
Krao Hasanee
2026-05-29 13:49:21 -04:00
parent 283511bf3a
commit c9998d4a8d
54 changed files with 2646 additions and 396 deletions
+212
View File
@@ -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 });
}
+74 -31
View File
@@ -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++;
+3 -3
View File
@@ -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),
}; };
} }
+3 -3
View File
@@ -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),
}; };
} }
+2 -2
View File
@@ -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),
}; };
} }
+2 -2
View File
@@ -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),
}; };
} }
+2 -2
View File
@@ -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),
}; };
} }
+3 -2
View File
@@ -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 });
} }
Executable → Regular
View File
Executable → Regular
View File
+109 -12
View File
@@ -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).
Generated Executable → Regular
View File
Executable → Regular
View File
Executable → Regular
View File
Executable → Regular
View File
Executable → Regular
View File

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Executable → Regular
View File

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

+69 -22
View File
@@ -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}`);
Executable → Regular
View File
Executable → Regular
View File
Executable → Regular
View File

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Executable → Regular
View File

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Executable → Regular
View File

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

View File
Executable → Regular
+10 -4
View File
@@ -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>
View File
Executable → Regular
View File
Executable → Regular
View File
Executable → Regular
View File
Executable → Regular
+39 -17
View File
@@ -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);
}
+21 -3
View File
@@ -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);
+83 -89
View File
@@ -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 });
}
}
Executable → Regular
View File
Executable → Regular
View File
+4 -1
View File
@@ -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();
+2 -13
View File
@@ -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);
}; };
+1107 -5
View File
File diff suppressed because it is too large Load Diff
Executable → Regular
View File
+2 -16
View File
@@ -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 && (
+2 -1
View File
@@ -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 }));
+4 -1
View File
@@ -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,
Executable → Regular
+352 -120
View File
@@ -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>
Executable → Regular
View File
+18 -10
View File
@@ -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)];
}); });
}; };
+73 -9
View File
@@ -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} />
+124
View File
@@ -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' },
});
});
+269
View File
@@ -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);
});
View File
@@ -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;
Executable → Regular
View File
Executable → Regular
View File