diff --git a/api/archive-orphan-folders.js b/api/archive-orphan-folders.js new file mode 100644 index 0000000..9fe1e0d --- /dev/null +++ b/api/archive-orphan-folders.js @@ -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 }); +} diff --git a/api/backfill-request-files.js b/api/backfill-request-files.js index a5469b9..0a7404e 100644 --- a/api/backfill-request-files.js +++ b/api/backfill-request-files.js @@ -1,6 +1,6 @@ import { createClient } from '@supabase/supabase-js'; -const FB_SOURCE = 'files'; +const FB_SOURCE = 'srv'; function normalizePath(path) { const raw = String(path || '/').trim(); @@ -31,7 +31,7 @@ function getConfig() { return { url, 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), }; } @@ -56,6 +56,37 @@ async function mkdir(config, path) { }).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) { return res.status(status).json(body); } @@ -80,6 +111,33 @@ export default async function handler(req, res) { const config = getConfig(); 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 const { data: rows, error } = await admin .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 }); } - - const results = { processed: 0, skipped: 0, errors: [] }; - for (const group of groups.values()) { if (group.files.length === 0) { results.skipped++; continue; } const revFolder = `R${String(group.versionNumber).padStart(2, '0')}`; - const companyDir = joinPath(config.clientRoot, safeName(group.companyName)); - const projectDir = joinPath(companyDir, 'Projects', safeName(group.projectName)); - const taskDir = joinPath(projectDir, safeName(group.taskTitle)); - const requestInfoDir = joinPath(taskDir, 'Request Info'); + const { requestInfoDir } = await ensureTaskStructure(config, group.companyName, group.projectName, group.taskTitle); 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); for (const file of group.files) { try { - // Get signed URL from Supabase Storage - const { data: signed, error: signedError } = await admin.storage + const { data: blob, error: downloadError } = await admin.storage .from('submissions') - .createSignedUrl(file.storage_path, 60); - if (signedError || !signed?.signedUrl) { - results.errors.push(`signed url failed: ${file.storage_path}`); + .download(file.storage_path); + if (downloadError || !blob) { + results.errors.push(`download failed: ${file.storage_path}`); continue; } - - // Download file from Supabase Storage - const fileRes = await fetch(signed.signedUrl); - if (!fileRes.ok) { - results.errors.push(`download failed: ${file.name}`); - continue; - } - const fileBuffer = await fileRes.arrayBuffer(); + const fileBuffer = await blob.arrayBuffer(); + const form = new FormData(); + form.append('file', new Blob([fileBuffer]), file.name); // Upload to FileBrowser const fbFilePath = joinPath(revDir, file.name); + if (await fbExists(config, fbFilePath)) { + results.skipped++; + continue; + } await fbFetch(config, 'POST', '/api/resources', { - params: { path: fbFilePath, override: 'true' }, - headers: { 'Content-Type': 'application/octet-stream' }, - body: fileBuffer, + params: { path: fbFilePath }, + body: form, }); results.processed++; diff --git a/api/delete-project.js b/api/delete-project.js index a4fd39d..72f9332 100644 --- a/api/delete-project.js +++ b/api/delete-project.js @@ -1,6 +1,6 @@ import { createClient } from '@supabase/supabase-js'; -const FB_SOURCE = 'files'; +const FB_SOURCE = 'srv'; function normalizePath(path) { const raw = String(path || '/').trim(); @@ -42,8 +42,8 @@ function getFbConfig() { return { url, token: process.env.FILEBROWSER_TOKEN || '', - clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients'), - archiveRoot: normalizePath(process.env.FILEBROWSER_ARCHIVE_ROOT || '/fourgebranding/Archive'), + clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/Clients'), + archiveRoot: normalizePath(process.env.FILEBROWSER_ARCHIVE_ROOT || '/Archive'), configured: Boolean(url) && Boolean(process.env.FILEBROWSER_TOKEN), }; } diff --git a/api/delete-task.js b/api/delete-task.js index 385906e..656246c 100644 --- a/api/delete-task.js +++ b/api/delete-task.js @@ -1,6 +1,6 @@ import { createClient } from '@supabase/supabase-js'; -const FB_SOURCE = 'files'; +const FB_SOURCE = 'srv'; function normalizePath(path) { const raw = String(path || '/').trim(); @@ -36,8 +36,8 @@ function getFbConfig() { return { url, token: process.env.FILEBROWSER_TOKEN || '', - clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients'), - archiveRoot: normalizePath(process.env.FILEBROWSER_ARCHIVE_ROOT || '/fourgebranding/Archive'), + clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/Clients'), + archiveRoot: normalizePath(process.env.FILEBROWSER_ARCHIVE_ROOT || '/Archive'), configured: Boolean(url) && Boolean(process.env.FILEBROWSER_TOKEN), }; } diff --git a/api/sync-company-folder.js b/api/sync-company-folder.js index d2bd8aa..32628bd 100644 --- a/api/sync-company-folder.js +++ b/api/sync-company-folder.js @@ -1,4 +1,4 @@ -const FB_SOURCE = 'files'; +const FB_SOURCE = 'srv'; function normalizePath(path) { const raw = String(path || '/').trim(); @@ -35,7 +35,7 @@ function getConfig() { return { url, 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), }; } diff --git a/api/sync-profile-folder.js b/api/sync-profile-folder.js index 4becb8a..73b89af 100644 --- a/api/sync-profile-folder.js +++ b/api/sync-profile-folder.js @@ -1,4 +1,4 @@ -const FB_SOURCE = 'files'; +const FB_SOURCE = 'srv'; const MEMBER_ROLES = new Set(['team', 'external']); function normalizePath(path) { @@ -36,7 +36,7 @@ function getConfig() { return { url, 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), }; } diff --git a/api/sync-project-folder.js b/api/sync-project-folder.js index c5ea011..e06a8a0 100644 --- a/api/sync-project-folder.js +++ b/api/sync-project-folder.js @@ -1,6 +1,6 @@ import { createClient } from '@supabase/supabase-js'; -const FB_SOURCE = 'files'; +const FB_SOURCE = 'srv'; function normalizePath(path) { const raw = String(path || '/').trim(); @@ -31,7 +31,7 @@ function getConfig() { return { url, 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), }; } diff --git a/api/sync-task-folder.js b/api/sync-task-folder.js index 1423fe2..ba3f3ee 100644 --- a/api/sync-task-folder.js +++ b/api/sync-task-folder.js @@ -1,4 +1,4 @@ -const FB_SOURCE = 'files'; +const FB_SOURCE = 'srv'; function normalizePath(path) { const raw = String(path || '/').trim(); @@ -29,7 +29,7 @@ function getConfig() { return { url, 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), }; } @@ -100,6 +100,7 @@ export default async function handler(req, res) { await mkdir(config, taskDir); await mkdir(config, joinPath(taskDir, 'Working Files')); 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 }); } diff --git a/eslint.config.js b/eslint.config.js old mode 100755 new mode 100644 diff --git a/index.html b/index.html old mode 100755 new mode 100644 diff --git a/layout.md b/layout.md index ace7094..47ae385 100644 --- a/layout.md +++ b/layout.md @@ -21,6 +21,7 @@ This is the single source of truth for dashboard/profile visual structure and UI - Accent: `#F5A523` - Card bg dark: `rgba(255,255,255,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 light: `rgba(0,0,0,0.08)` - 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` - `padding: 18px 21px` - `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) ## 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) - 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 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 - 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: - `font-size: 10px`, `font-weight: 500`, uppercase, `letter-spacing: 0.6px` - 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` - secondary/metrics text: `12px` - 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%` - Client Highlight column widths: - 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 - 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 - Main profile card uses widget shell +- Profile card width is determined by the profile page grid, not by the card itself. - Internal card layout: - 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` + - 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` - - 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 -- Modal: - - overlay: fixed inset, `z-index: 1200`, bg `rgba(0,0,0,0.58)`, blur `6px` - - overlay padding: `24px` - - modal width: `min(620px, 100%)` - - modal max-height: `calc(100vh - 48px)`, `overflow-y: auto` +- Profile page left column includes a `Tasks In Progress` card above `Recent Activity` +- Profile `Tasks In Progress` follows the dashboard table/card pattern: + - same widget shell and header treatment + - same show-all behavior (5 visible by default) + - 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/` + - company folders live at `Clients/` + - project folders live at `Clients//Projects/` + - every project auto-creates `00 Project Files` + - task folders live at `Clients//Projects//` + - 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//Projects//` instead of deleting it + - deleting a project in the portal moves its project folder into `Archive/Clients//Projects/` 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/` when no team or subcontractor profile matches that folder name + - orphan project folders move to `Archive/Clients//Projects/` 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/` + - 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//Projects/` +- 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 - Dashboard/profile widgets: `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%` ## 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`. - Light hover surface baseline: `rgba(0,0,0,0.08)`. - 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 uses stronger temporary contrast (`bg` + thin border), - 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 - 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 3. Net Profit 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). diff --git a/package-lock.json b/package-lock.json old mode 100755 new mode 100644 diff --git a/package.json b/package.json old mode 100755 new mode 100644 diff --git a/public/.htaccess b/public/.htaccess old mode 100755 new mode 100644 diff --git a/public/font.ttf b/public/font.ttf old mode 100755 new mode 100644 diff --git a/public/icons.svg b/public/icons.svg old mode 100755 new mode 100644 diff --git a/public/logo.png b/public/logo.png old mode 100755 new mode 100644 diff --git a/scripts/backfill-request-files.mjs b/scripts/backfill-request-files.mjs index f9e3dce..c56851b 100644 --- a/scripts/backfill-request-files.mjs +++ b/scripts/backfill-request-files.mjs @@ -19,8 +19,8 @@ const SUPABASE_URL = env.VITE_SUPABASE_URL || env.SUPABASE_URL; const SERVICE_ROLE_KEY = env.SUPABASE_SERVICE_ROLE_KEY; const FB_URL = (env.FILEBROWSER_URL || '').replace(/\/+$/, ''); const FB_TOKEN = env.FILEBROWSER_TOKEN; -const CLIENT_ROOT = env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients'; -const FB_SOURCE = 'files'; +const CLIENT_ROOT = env.FILEBROWSER_CLIENT_ROOT || '/Clients'; +const FB_SOURCE = 'srv'; 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); } @@ -67,7 +67,59 @@ async function mkdir(path) { 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() { + 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 .from('submission_files') .select(` @@ -108,6 +160,7 @@ async function main() { 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`); let processed = 0, skipped = 0, errors = 0; @@ -116,38 +169,32 @@ async function main() { if (group.files.length === 0) { skipped++; continue; } const revFolder = `R${String(group.versionNumber).padStart(2, '0')}`; - const companyDir = joinPath(CLIENT_ROOT, safeName(group.companyName)); - const projectDir = joinPath(companyDir, 'Projects', safeName(group.projectName)); - const taskDir = joinPath(projectDir, safeName(group.taskTitle)); - const requestInfoDir = joinPath(taskDir, 'Request Info'); + const { requestInfoDir } = await ensureTaskStructure(group.companyName, group.projectName, group.taskTitle); const revDir = joinPath(requestInfoDir, revFolder); 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); for (const file of group.files) { try { - const { data: signed, error: signErr } = await admin.storage + const { data: blob, error: downloadErr } = await admin.storage .from('submissions') - .createSignedUrl(file.storage_path, 120); - - if (signErr || !signed?.signedUrl) throw new Error(`signed url failed: ${signErr?.message}`); - - const fileRes = await fetch(signed.signedUrl); - if (!fileRes.ok) throw new Error(`download failed: ${fileRes.status}`); - const fileBuffer = await fileRes.arrayBuffer(); + .download(file.storage_path); + if (downloadErr || !blob) throw new Error(`download failed: ${downloadErr?.message}`); + const fileBuffer = await blob.arrayBuffer(); + const form = new FormData(); + form.append('file', new Blob([fileBuffer]), 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', { - params: { path: fbFilePath, override: 'true' }, - headers: { 'Content-Type': 'application/octet-stream' }, - body: fileBuffer, + params: { path: fbFilePath }, + body: form, }); console.log(` ✓ ${file.name}`); diff --git a/src/App.css b/src/App.css old mode 100755 new mode 100644 diff --git a/src/App.jsx b/src/App.jsx old mode 100755 new mode 100644 diff --git a/src/assets/hero.png b/src/assets/hero.png old mode 100755 new mode 100644 diff --git a/src/assets/react.svg b/src/assets/react.svg old mode 100755 new mode 100644 diff --git a/src/assets/vite.svg b/src/assets/vite.svg old mode 100755 new mode 100644 diff --git a/src/components/FileAttachment.jsx b/src/components/FileAttachment.jsx old mode 100755 new mode 100644 diff --git a/src/components/Layout.jsx b/src/components/Layout.jsx old mode 100755 new mode 100644 index 531f583..888ecc6 --- a/src/components/Layout.jsx +++ b/src/components/Layout.jsx @@ -139,10 +139,13 @@ export default function Layout({ children }) { const timeOfDay = hour < 12 ? 'morning' : hour < 17 ? 'afternoon' : 'evening'; const firstName = currentUser?.name?.split(' ')[0] || ''; 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 ? 'Account details and security settings.' - : "Here's what's happening today."; + : isFileSharingRoute + ? 'Browse, share and manage files.' + : "Here's what's happening today."; useEffect(() => { document.documentElement.setAttribute('data-theme', theme); @@ -277,12 +280,15 @@ export default function Layout({ children }) {
- {avatarOpen && (
- +
diff --git a/src/components/ProtectedRoute.jsx b/src/components/ProtectedRoute.jsx old mode 100755 new mode 100644 diff --git a/src/components/StatusBadge.jsx b/src/components/StatusBadge.jsx old mode 100755 new mode 100644 diff --git a/src/context/AuthContext.jsx b/src/context/AuthContext.jsx old mode 100755 new mode 100644 diff --git a/src/data/mockData.js b/src/data/mockData.js old mode 100755 new mode 100644 diff --git a/src/index.css b/src/index.css old mode 100755 new mode 100644 index 517bd13..6659ab4 --- a/src/index.css +++ b/src/index.css @@ -30,6 +30,7 @@ --border: rgba(245, 165, 35, 0.15); --interactive-hover-border: rgba(245, 165, 35, 0.3); --interactive-row-hover: rgba(255,255,255,0.03); + --file-row-alt-bg: rgba(255,255,255,0.02); --danger: #ef4444; --success: #22c55e; } @@ -56,6 +57,7 @@ --border: rgba(0,0,0,0.1); --interactive-hover-border: rgba(0,0,0,0.2); --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="email"], @@ -92,8 +94,8 @@ [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-info { background: #eff6ff; color: #2563eb; border-color: #bfdbfe; } -[data-theme="light"] .btn-outline { color: #1a1a1a; border-color: #d0d0d0; } -[data-theme="light"] .btn-outline:hover { background: #f0f0f0; } +[data-theme="light"] .btn-outline { color: var(--text-primary); border-color: var(--border); } +[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:hover { background: var(--danger); color: white; } [data-theme="light"] select option { background: #fff; color: #1a1a1a; } @@ -117,7 +119,7 @@ body { .sidebar { width: 76px; min-width: 76px; - background: var(--sidebar-bg); + background: var(--card-bg); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); display: flex; @@ -245,7 +247,14 @@ body { .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-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-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; } @@ -902,10 +911,10 @@ body { /* Buttons */ .btn { display: inline-flex; align-items: center; gap: 6px; - height: var(--h-control); padding: 0 14px; border-radius: 4px; font-size: 13px; - font-weight: 400; cursor: pointer; border: 1px solid transparent; + height: var(--h-control); padding: 0 12px; border-radius: 8px; font-size: 11px; + font-weight: 500; cursor: pointer; border: 1px solid transparent; 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 { opacity: 0.65; @@ -929,11 +938,11 @@ body { @keyframes btn-spin { 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:hover { background: var(--accent-hover); } .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:hover { background: #16a34a; } .btn-warning { background: var(--accent); color: #111; } @@ -1080,7 +1089,7 @@ select option { background: #222; color: #fff; } /* 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: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-icon { display: none; } .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; } /* 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, -.site-header-dropdown-item:hover, -.site-header-avatar-item:hover { +.site-header-dropdown-item:hover { background-color: rgba(255,255,255,0.12); } .site-header-search-btn:hover, -.site-header-theme-toggle:hover { +.site-header-theme-toggle:hover, +.site-header-avatar-item:hover { 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"] .site-header-dropdown-item:hover, -[data-theme="dark"] .site-header-avatar-item:hover { +[data-theme="dark"] .site-header-dropdown-item:hover { 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"] .site-header-dropdown-item:hover, -[data-theme="light"] .site-header-avatar-item:hover { +[data-theme="light"] .site-header-dropdown-item:hover { background-color: rgba(0,0,0,0.14); border-color: rgba(0,0,0,0.18); color: #0d0d0d; } +[data-theme="light"] .site-header-avatar-item:hover { + color: var(--accent); +} diff --git a/src/lib/activityLog.js b/src/lib/activityLog.js index 339625a..6f53376 100644 --- a/src/lib/activityLog.js +++ b/src/lib/activityLog.js @@ -1,13 +1,31 @@ import { supabase } from './supabase'; 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({ - actor_id: actorId || null, + actor_id: normalizedActorId, actor_name: actorName || null, action, - task_id: taskId || null, + task_id: normalizedTaskId, task_title: taskTitle || null, - project_id: projectId || null, + project_id: normalizedProjectId, project_name: projectName || null, }); if (error) console.error('logActivity failed:', action, error); diff --git a/src/lib/filebrowserFolders.js b/src/lib/filebrowserFolders.js index e361e4a..c00d391 100644 --- a/src/lib/filebrowserFolders.js +++ b/src/lib/filebrowserFolders.js @@ -1,33 +1,19 @@ 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(); if (!session?.access_token) return; - - await fetch(`/api/filebrowser?action=${action}`, { - method, - headers: { - Authorization: `Bearer ${session.access_token}`, - 'Content-Type': 'application/json', - }, - body: body ? JSON.stringify(body) : undefined, - }).catch(() => {}); + const headers = { + Authorization: `Bearer ${session.access_token}`, + 'X-Operation': op, + 'X-Path': path, + ...extra.headers, + }; + 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) { return String(v || '').trim() .replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-') @@ -35,86 +21,94 @@ function safeName(v) { .replace(/^-+|-+$/g, ''); } -// Upload files to the correct Request Info/{rev} folder in FileBrowser. -// companyName is required. versionNumber defaults to 0 (R00). -// Best-effort — call with .catch(() => {}) so failures don't block submission. +export async function createProjectFolder(companyName, projectName) { + if (!companyName || !projectName) return; + 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) { 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 proj = safeName(projectName); const task = safeName(taskTitle); const rev = `R${String(versionNumber).padStart(2, '0')}`; + const base = `/Clients/${co}/Projects/${proj}/${task}/Request Info/${rev}`; - // Build virtual path segments for mkdir. - // Clients: virtual root is per-company; company folder already exists — start one level in. - // Team/external: full path under /Clients/{co}/... - let mkdirs; - let revPath; - - if (role === 'client') { - revPath = `/${co}/Projects/${proj}/${task}/Request Info/${rev}`; - mkdirs = [ - { 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 }, - ]; + const mkdirs = [ + `/Clients/${co}/`, + `/Clients/${co}/Projects/`, + `/Clients/${co}/Projects/${proj}/`, + `/Clients/${co}/Projects/${proj}/${task}/`, + `/Clients/${co}/Projects/${proj}/${task}/Request Info/`, + `/Clients/${co}/Projects/${proj}/${task}/Working Files/`, + `/Clients/${co}/Projects/${proj}/${task}/Old Books/`, + `${base}/`, + ]; + for (const dir of mkdirs) { + await fbq('mkdir', dir); } - for (const seg of mkdirs) { - await fetch('/api/filebrowser?action=mkdir', { - 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; + const { data: { session } } = await supabase.auth.getSession(); + if (!session?.access_token) return; 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', - headers: { Authorization: `Bearer ${token}`, 'Content-Type': file.type || 'application/octet-stream' }, - body: file, + headers: { + Authorization: `Bearer ${session.access_token}`, + 'X-Operation': 'upload', + 'X-Path': filePath, + }, + body: fd, }).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 }); - } -} diff --git a/src/lib/supabase.js b/src/lib/supabase.js old mode 100755 new mode 100644 diff --git a/src/main.jsx b/src/main.jsx old mode 100755 new mode 100644 diff --git a/src/pages/CompaniesPage.jsx b/src/pages/CompaniesPage.jsx index b76168d..9e80e5d 100644 --- a/src/pages/CompaniesPage.jsx +++ b/src/pages/CompaniesPage.jsx @@ -7,7 +7,7 @@ import { supabase } from '../lib/supabase'; import { useAuth } from '../context/AuthContext'; import { deleteCompanyData } from '../lib/deleteHelpers'; import { readPageCache, writePageCache } from '../lib/pageCache'; -import { createClientFolder, backfillClientFolders } from '../lib/filebrowserFolders'; +import { createClientFolder, backfillClientFolders, createTeamMemberFolder } from '../lib/filebrowserFolders'; // ── Team view ───────────────────────────────────────────────────────────────── @@ -113,6 +113,9 @@ function TeamCompanies() { const errBody = error?.context ? await error.context.json().catch(() => null) : null; const errMsg = errBody?.error || data?.error || error?.message; if (errMsg) { setUserError(errMsg); return; } + if (userForm.role === 'team' || userForm.role === 'external') { + createTeamMemberFolder(userForm.name.trim()).catch(() => {}); + } setShowNewUser(false); setUserForm({ name: '', email: '', password: '', company_id: '', role: 'client' }); load(); diff --git a/src/pages/CompanyDetail.jsx b/src/pages/CompanyDetail.jsx index 9045994..1562865 100644 --- a/src/pages/CompanyDetail.jsx +++ b/src/pages/CompanyDetail.jsx @@ -6,8 +6,8 @@ import { supabase } from '../lib/supabase'; import { useAuth } from '../context/AuthContext'; import { serviceTypes } from '../data/mockData'; import { cleanupTaskStorage, deleteCompanyData } from '../lib/deleteHelpers'; -import { renameClientFolder, backfillClientFolders } from '../lib/filebrowserFolders'; import { logActivity } from '../lib/activityLog'; +import { createProjectFolder } from '../lib/filebrowserFolders'; export default function CompanyDetail() { const { id } = useParams(); @@ -73,8 +73,6 @@ export default function CompanyDetail() { setSavingName(true); const oldName = company.name; await supabase.from('companies').update({ name: nameVal.trim() }).eq('id', id); - renameClientFolder(oldName, nameVal.trim()).catch(() => {}); - backfillClientFolders().catch(() => {}); setCompany(c => ({ ...c, name: nameVal.trim() })); setEditingName(false); setSavingName(false); @@ -176,19 +174,10 @@ export default function CompanyDetail() { }).select().single(); if (data) { 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]); setNewProjectName(''); 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); }; diff --git a/src/pages/FileSharing.jsx b/src/pages/FileSharing.jsx index 18dbae9..9956b53 100644 --- a/src/pages/FileSharing.jsx +++ b/src/pages/FileSharing.jsx @@ -1,16 +1,1118 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import JSZip from 'jszip'; import Layout from '../components/Layout'; -import FileBrowser from '../components/FileBrowser'; +import SortTh from '../components/SortTh'; +import { supabase } from '../lib/supabase'; +import { useAuth } from '../context/AuthContext'; + +const CARD = { + background: 'var(--card-bg)', + border: '1px solid var(--border)', + borderRadius: 8, + backdropFilter: 'blur(12px)', + WebkitBackdropFilter: 'blur(12px)', +}; + +const LABEL = { + fontSize: 11, + fontWeight: 500, + color: 'var(--text-secondary)', + letterSpacing: 0.8, + textTransform: 'uppercase', + marginBottom: 14, +}; + +const TABLE_TH = { + fontSize: 10, + fontWeight: 500, + color: 'var(--text-muted)', + letterSpacing: 0.6, + textTransform: 'uppercase', + padding: '0 0 12px', + border: 'none', + background: 'transparent', + verticalAlign: 'top', +}; + +const TABLE_TD = { + padding: '5px', + fontSize: 13, + color: 'var(--text-primary)', + border: 'none', + background: 'transparent', +}; + +function formatBytes(value) { + if (!value) return '—'; + const units = ['B', 'KB', 'MB', 'GB']; + const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), 3); + return `${(value / 1024 ** index).toFixed(index === 0 ? 0 : 1)} ${units[index]}`; +} + +function formatDate(value) { + if (!value) return '—'; + const date = new Date(value); + const dateLabel = date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + const timeLabel = date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); + return `${dateLabel} ${timeLabel}`; +} + +function extOf(name) { + const dot = name.lastIndexOf('.'); + return dot > 0 ? name.slice(dot + 1).toUpperCase() : 'FILE'; +} + +function fileIconStyle(ext) { + const kind = ext.toLowerCase(); + if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'avif', 'heic'].includes(kind)) return { bg: '#16a34a', color: '#fff' }; + if (['mp4', 'mov', 'avi', 'mkv', 'webm'].includes(kind)) return { bg: '#7c3aed', color: '#fff' }; + if (['mp3', 'wav', 'flac', 'aac'].includes(kind)) return { bg: '#db2777', color: '#fff' }; + if (kind === 'pdf') return { bg: '#dc2626', color: '#fff' }; + if (['doc', 'docx'].includes(kind)) return { bg: '#2563eb', color: '#fff' }; + if (['xls', 'xlsx', 'csv'].includes(kind)) return { bg: '#16a34a', color: '#fff' }; + if (['ppt', 'pptx'].includes(kind)) return { bg: '#ea580c', color: '#fff' }; + if (['zip', 'rar', '7z'].includes(kind)) return { bg: '#92400e', color: '#fff' }; + if (['ai', 'eps'].includes(kind)) return { bg: '#ff6c00', color: '#fff' }; + if (['psd', 'psb'].includes(kind)) return { bg: '#001e36', color: '#31a8ff' }; + if (['fig', 'sketch'].includes(kind)) return { bg: '#7c3aed', color: '#fff' }; + return { bg: '#475569', color: '#fff' }; +} + +function FileIcon({ name, isDir }) { + if (isDir) { + return ( +
+ + + +
+ ); + } + + const ext = extOf(name); + const { bg, color } = fileIconStyle(ext); + return ( +
+ {ext.slice(0, 4)} +
+ ); +} + +const FBQ_FN = `${import.meta.env.VITE_SUPABASE_URL}/functions/v1/fbq-proxy`; + +async function fbqProxy(op, path, extra = {}) { + const { data: { session } } = await supabase.auth.getSession(); + const headers = { + Authorization: `Bearer ${session?.access_token ?? ''}`, + 'X-Operation': op, + 'X-Path': path, + ...extra.headers, + }; + const response = await fetch(FBQ_FN, { + method: 'POST', + headers, + body: extra.body ?? undefined, + }); + if (!response.ok) { + const text = await response.text(); + throw new Error(text || `Error ${response.status}`); + } + return response; +} + +function rootPath(user) { + if (!user || user.role === 'team' || user.role === 'external') return '/'; + const companies = user.companies?.filter(Boolean) ?? []; + if (companies.length > 1) return '/Clients'; + const name = companies[0]?.name ?? user.company?.name; + return name ? `/Clients/${name}` : '/'; +} + +function parsePath(path) { + return path.split('/').filter(Boolean); +} + +function buildPath(parts) { + return `/${parts.join('/')}`; +} + +const TREE_TOGGLE_W = 18; export default function FileSharing() { + const { currentUser } = useAuth(); + const home = rootPath(currentUser); + const isTeam = currentUser?.role === 'team'; + const pinsStorageKey = useMemo(() => `fbq_pins:${currentUser?.id ?? 'anon'}`, [currentUser?.id]); + + const [path, setPath] = useState(home); + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [pins, setPins] = useState([]); + const [selected, setSelected] = useState(new Set()); + const [uploading, setUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(null); + const [downloadingSelection, setDownloadingSelection] = useState(false); + const [ctxMenu, setCtxMenu] = useState(null); + const [clipboard, setClipboard] = useState(null); + const [hoveredNavPath, setHoveredNavPath] = useState(null); + const [dragActive, setDragActive] = useState(false); + const [sortKey, setSortKey] = useState('name'); + const [sortDir, setSortDir] = useState('asc'); + const [expanded, setExpanded] = useState(() => new Set([home])); + const [, setNavVersion] = useState(0); + const navCache = useRef(new Map()); + const dragDepthRef = useRef(0); + const rootRef = useRef(null); + const tableScrollRef = useRef(null); + const tableHeadRef = useRef(null); + const firstBodyRowRef = useRef(null); + const [contentHeight, setContentHeight] = useState(null); + const [tableHeaderHeight, setTableHeaderHeight] = useState(0); + const [tableRowHeight, setTableRowHeight] = useState(0); + const [tableViewportHeight, setTableViewportHeight] = useState(0); + + useEffect(() => { + if (!ctxMenu) return undefined; + const close = () => setCtxMenu(null); + const onKey = (event) => { + if (event.key === 'Escape') setCtxMenu(null); + }; + window.addEventListener('click', close); + window.addEventListener('keydown', onKey); + return () => { + window.removeEventListener('click', close); + window.removeEventListener('keydown', onKey); + }; + }, [ctxMenu]); + + const load = useCallback(async (nextPath) => { + setLoading(true); + setError(''); + setSelected(new Set()); + try { + const response = await fbqProxy('list', nextPath); + const data = await response.json(); + setEntries(Array.isArray(data) ? data : [...(data?.folders ?? []), ...(data?.files ?? [])]); + } catch (err) { + setError(err?.message || 'Failed to load files.'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(path); + }, [path, load]); + + useEffect(() => { + try { + setPins(JSON.parse(localStorage.getItem(pinsStorageKey) || '[]')); + } catch { + setPins([]); + } + }, [pinsStorageKey]); + + async function loadNavPath(nextPath) { + if (navCache.current.has(nextPath)) return; + navCache.current.set(nextPath, []); + try { + const response = await fbqProxy('list', nextPath); + const data = await response.json(); + const folders = (data?.folders ?? []).map((folder) => folder.name ?? folder.filename ?? '').filter(Boolean); + navCache.current.set(nextPath, folders); + setNavVersion((value) => value + 1); + } catch { + navCache.current.delete(nextPath); + } + } + + useEffect(() => { + loadNavPath(home); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + function measure() { + const root = rootRef.current; + if (!root) return; + const main = root.closest('.main-content'); + const header = main?.querySelector('.site-header'); + if (!main || !header) return; + const styles = window.getComputedStyle(main); + const paddingTop = parseFloat(styles.paddingTop || '0'); + const paddingBottom = parseFloat(styles.paddingBottom || '0'); + setContentHeight(Math.max(320, main.clientHeight - paddingTop - paddingBottom - header.offsetHeight)); + } + + measure(); + window.addEventListener('resize', measure); + return () => window.removeEventListener('resize', measure); + }, []); + + useEffect(() => { + function measureTableHeader() { + setTableHeaderHeight(tableHeadRef.current?.offsetHeight ?? 0); + setTableRowHeight(firstBodyRowRef.current?.offsetHeight ?? 0); + } + + measureTableHeader(); + window.addEventListener('resize', measureTableHeader); + return () => window.removeEventListener('resize', measureTableHeader); + }, [entries.length, loading, sortDir, sortKey]); + + useEffect(() => { + const node = tableScrollRef.current; + if (!node) return undefined; + + const measureViewport = () => setTableViewportHeight(node.clientHeight); + measureViewport(); + + const observer = new ResizeObserver(measureViewport); + observer.observe(node); + window.addEventListener('resize', measureViewport); + + return () => { + observer.disconnect(); + window.removeEventListener('resize', measureViewport); + }; + }, [contentHeight, entries.length, loading]); + + useEffect(() => { + const segments = parsePath(path); + const homeParts = parsePath(home); + const toExpand = [home]; + for (let index = homeParts.length; index < segments.length; index += 1) { + toExpand.push(buildPath(segments.slice(0, index + 1))); + } + setExpanded((prev) => { + const next = new Set(prev); + toExpand.forEach((value) => next.add(value)); + return next; + }); + toExpand.forEach(loadNavPath); + loadNavPath(path); + }, [path]); // eslint-disable-line react-hooks/exhaustive-deps + + function toggleNav(nextPath) { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(nextPath)) { + next.delete(nextPath); + } else { + next.add(nextPath); + loadNavPath(nextPath); + } + return next; + }); + } + + function renderNavTree(nodePath, depth) { // eslint-disable-line no-unused-vars + const folders = navCache.current.get(nodePath) ?? []; + if (!folders.length) return null; + return folders.map((folderName) => { + const childPath = nodePath === '/' ? `/${folderName}` : `${nodePath}/${folderName}`; + const isExpanded = expanded.has(childPath); + return ( +
+
+ + +
+ {isExpanded && ( +
+ + {renderNavTree(childPath, depth + 1)} +
+ )} +
+ ); + }); + } + + function navigate(nextPath) { + setPath(nextPath); + } + + function breadcrumbs() { + const segments = parsePath(path); + const homeParts = parsePath(home); + const crumbs = [{ label: 'Home', path: home }]; + for (let index = homeParts.length; index < segments.length; index += 1) { + crumbs.push({ label: segments[index], path: buildPath(segments.slice(0, index + 1)) }); + } + return crumbs; + } + + function toggleSelect(name) { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(name)) next.delete(name); + else next.add(name); + return next; + }); + } + + function toggleSort(column) { + if (sortKey === column) { + setSortDir((prev) => (prev === 'asc' ? 'desc' : 'asc')); + return; + } + setSortKey(column); + setSortDir(column === 'modified' ? 'desc' : 'asc'); + } + + function pin(label, nextPath) { + const next = [...pins.filter((item) => item.path !== nextPath), { label, path: nextPath }]; + setPins(next); + localStorage.setItem(pinsStorageKey, JSON.stringify(next)); + } + + function unpin(nextPath) { + const next = pins.filter((item) => item.path !== nextPath); + setPins(next); + localStorage.setItem(pinsStorageKey, JSON.stringify(next)); + } + + async function handleDownload(entry) { + const nextPath = entry.path ?? (path === '/' ? `/${entry.name}` : `${path}/${entry.name}`); + try { + const blob = await (await fbqProxy('download', nextPath)).blob(); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = entry.name; + link.click(); + URL.revokeObjectURL(url); + } catch { + setError('Download failed.'); + } + } + + async function addPathToZip(zip, targetPath, zipPath = '') { + const response = await fbqProxy('list', targetPath); + const data = await response.json(); + const folders = data?.folders ?? []; + const files = data?.files ?? []; + + for (const folder of folders) { + const folderName = folder.name ?? folder.filename ?? ''; + if (!folderName) continue; + const childPath = targetPath === '/' ? `/${folderName}` : `${targetPath}/${folderName}`; + const childZipPath = zipPath ? `${zipPath}/${folderName}` : folderName; + await addPathToZip(zip, childPath, childZipPath); + } + + for (const file of files) { + const fileName = file.name ?? file.filename ?? ''; + if (!fileName) continue; + const filePath = file.path ?? (targetPath === '/' ? `/${fileName}` : `${targetPath}/${fileName}`); + const blob = await (await fbqProxy('download', filePath)).blob(); + zip.file(zipPath ? `${zipPath}/${fileName}` : fileName, blob); + } + } + + async function handleDownloadSelected() { + if (!selected.size) return; + setDownloadingSelection(true); + setError(''); + try { + const selectedEntries = [...selected] + .map((name) => entries.find((entry) => (entry.name ?? entry.filename) === name)) + .filter(Boolean); + + if (selectedEntries.length === 1) { + const [entry] = selectedEntries; + const name = entry.name ?? entry.filename ?? ''; + const targetPath = entry.path ?? (path === '/' ? `/${name}` : `${path}/${name}`); + const isDir = entry.type === 'directory' || entry.isDir; + + if (!isDir) { + const blob = await (await fbqProxy('download', targetPath)).blob(); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = name; + link.click(); + URL.revokeObjectURL(url); + return; + } + + const zip = new JSZip(); + await addPathToZip(zip, targetPath, name); + const blob = await zip.generateAsync({ type: 'blob' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `${name}.zip`; + link.click(); + URL.revokeObjectURL(url); + return; + } + + const zip = new JSZip(); + + for (const entry of selectedEntries) { + const name = entry.name ?? entry.filename ?? ''; + const targetPath = entry.path ?? (path === '/' ? `/${name}` : `${path}/${name}`); + const isDir = entry.type === 'directory' || entry.isDir; + if (isDir) { + await addPathToZip(zip, targetPath, name); + continue; + } + const blob = await (await fbqProxy('download', targetPath)).blob(); + zip.file(name, blob); + } + + const blob = await zip.generateAsync({ type: 'blob' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + const folderName = parsePath(path).at(-1) || 'files'; + link.href = url; + link.download = `${folderName}-selection.zip`; + link.click(); + URL.revokeObjectURL(url); + } catch { + setError('Download failed.'); + } finally { + setDownloadingSelection(false); + } + } + + async function handlePaste(destPath) { + if (!clipboard) return; + try { + await fbqProxy(clipboard.mode, destPath, { + headers: { 'X-Files': JSON.stringify(clipboard.items.map((i) => i.path)) }, + }); + if (clipboard.mode === 'move') setClipboard(null); + load(path); + } catch { + setError('Paste failed.'); + } + } + + async function handleDelete() { + if (!selected.size) return; + if (!window.confirm(`Delete ${selected.size} item(s)?`)) return; + const filePaths = [...selected].map((name) => ( + entries.find((entry) => (entry.name ?? entry.filename) === name)?.path + ?? (path === '/' ? `/${name}` : `${path}/${name}`) + )); + try { + await fbqProxy('delete', path, { headers: { 'X-Files': JSON.stringify(filePaths) } }); + load(path); + } catch { + setError('Delete failed.'); + } + } + + async function uploadFiles(files) { + if (!files.length) return; + setUploading(true); + setUploadProgress(0); + try { + const dirSet = new Set(); + files.forEach(({ relativePath }) => { + const segments = relativePath.split('/').filter(Boolean); + for (let index = 1; index < segments.length; index += 1) { + dirSet.add(segments.slice(0, index).join('/')); + } + }); + + const dirs = [...dirSet].sort((left, right) => left.split('/').length - right.split('/').length); + for (const dir of dirs) { + const dirPath = `${path === '/' ? '' : path}/${dir}/`; + try { + await fbqProxy('mkdir', dirPath); + } catch {} + } + + for (let index = 0; index < files.length; index += 1) { + const { file, relativePath } = files[index]; + const formData = new FormData(); + formData.append('file', file); + await fbqProxy('upload', path === '/' ? `/${relativePath}` : `${path}/${relativePath}`, { body: formData }); + setUploadProgress(Math.round(((index + 1) / files.length) * 100)); + } + load(path); + } catch { + setError('Upload failed.'); + } finally { + setUploading(false); + setUploadProgress(null); + } + } + + async function collectEntry(entry, parent = '') { + if (entry.isFile) { + return [await new Promise((resolve, reject) => entry.file((file) => resolve({ file, relativePath: parent ? `${parent}/${file.name}` : file.name }), reject))]; + } + if (!entry.isDirectory) return []; + const nextParent = parent ? `${parent}/${entry.name}` : entry.name; + const reader = entry.createReader(); + const children = []; + while (true) { + const batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject)); + if (!batch.length) break; + children.push(...batch); + } + return (await Promise.all(children.map((child) => collectEntry(child, nextParent)))).flat(); + } + + async function handleDrop(event) { + event.preventDefault(); + event.stopPropagation(); + dragDepthRef.current = 0; + setDragActive(false); + + const entriesToRead = [...(event.dataTransfer?.items ?? [])] + .filter((item) => item.kind === 'file') + .map((item) => item.webkitGetAsEntry?.()) + .filter(Boolean); + + if (entriesToRead.length) { + await uploadFiles((await Promise.all(entriesToRead.map((entry) => collectEntry(entry)))).flat()); + return; + } + + await uploadFiles( + [...(event.dataTransfer?.files ?? [])].map((file) => ({ + file, + relativePath: file.webkitRelativePath || file.name, + })), + ); + } + + function handleDragEnter(event) { + event.preventDefault(); + event.stopPropagation(); + dragDepthRef.current += 1; + setDragActive(true); + } + + function handleDragOver(event) { + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = 'copy'; + } + + function handleDragLeave(event) { + event.preventDefault(); + event.stopPropagation(); + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + if (dragDepthRef.current === 0) setDragActive(false); + } + + function openCtxMenu(event, entry, childPath) { + event.preventDefault(); + event.stopPropagation(); + setCtxMenu({ x: event.clientX, y: event.clientY, entry, childPath }); + } + + const crumbs = breadcrumbs(); + + const sortedEntries = useMemo(() => { + function valueOf(entry, column) { + if (column === 'name') return (entry.name ?? entry.filename ?? '').toLowerCase(); + if (column === 'size') return Number(entry.size ?? -1); + if (column === 'modified') return new Date(entry.modified ?? entry.lastModified ?? 0).getTime(); + return ''; + } + + return [...entries].sort((left, right) => { + const leftIsDir = left.type === 'directory' || left.isDir; + const rightIsDir = right.type === 'directory' || right.isDir; + if (leftIsDir !== rightIsDir) return leftIsDir ? -1 : 1; + + let result = 0; + const leftValue = valueOf(left, sortKey); + const rightValue = valueOf(right, sortKey); + if (typeof leftValue === 'string' && typeof rightValue === 'string') { + result = leftValue.localeCompare(rightValue); + } else if (leftValue === rightValue) { + result = 0; + } else { + result = leftValue > rightValue ? 1 : -1; + } + + if (result === 0) { + const leftName = (left.name ?? left.filename ?? '').toLowerCase(); + const rightName = (right.name ?? right.filename ?? '').toLowerCase(); + result = leftName.localeCompare(rightName); + } + + return sortDir === 'asc' ? result : -result; + }); + }, [entries, sortDir, sortKey]); + + const fillerRowCount = useMemo(() => { + if (!entries.length || !tableRowHeight || !tableViewportHeight) return 0; + const availableHeight = tableViewportHeight - tableHeaderHeight; + if (availableHeight <= 0) return 0; + const visibleRowCount = Math.ceil(availableHeight / tableRowHeight) + 1; + return Math.max(0, visibleRowCount - sortedEntries.length); + }, [entries.length, sortedEntries.length, tableHeaderHeight, tableRowHeight, tableViewportHeight]); + return ( -
-
-
File Sharing
-
Shared workspace for team members and subcontractors.
+
+
+
+
Pinned
+ {pins.length === 0 ? ( +
Right-click a folder to pin it.
+ ) : ( +
+ {pins.map((pinItem) => ( +
+ + +
+ ))} +
+ )} +
+ +
+
Navigation
+
+ + {renderNavTree(home, 0)} +
+
+
+ +
+
+ {selected.size > 0 && ( +
+ + +
+ )} + +
0 ? 286 : 0 }}> +
+
File Browser
+
+ Drag and drop files or folders here. +
+
+ {crumbs.map((crumb, index) => ( + + {index > 0 && /} + + + ))} +
+
+
+ {uploading &&
Uploading {uploadProgress}%
} +
+
+
+ +
{ + if (e.target === e.currentTarget || e.target.tagName === 'TABLE' || e.target.closest('table')?.parentElement === e.currentTarget) { + e.preventDefault(); + if (clipboard) setCtxMenu({ x: e.clientX, y: e.clientY, entry: null, childPath: null }); + } + }} + > + {error &&
{error}
} + {loading ? ( +
Loading...
+ ) : entries.length === 0 ? ( +
This folder is empty.
+ ) : ( + + + + + + Name + + + Size + + + Modified + + + + + {sortedEntries.map((entry, index) => { + const name = entry.name ?? entry.filename ?? ''; + const isDir = entry.type === 'directory' || entry.isDir; + const childPath = entry.path ?? (path === '/' ? `/${name}` : `${path}/${name}`); + const rowBg = index % 2 === 0 ? 'var(--file-row-alt-bg)' : 'transparent'; + + return ( + isDir && navigate(childPath)} + onContextMenu={(event) => openCtxMenu(event, entry, childPath)} + > + + + + + + ); + })} + {Array.from({ length: fillerRowCount }).map((_, index) => { + const rowBg = (sortedEntries.length + index) % 2 === 0 ? 'var(--file-row-alt-bg)' : 'transparent'; + return ( + + + + + + + ); + })} + +
+ 0} onChange={(event) => setSelected(event.target.checked ? new Set(entries.map((entry) => entry.name ?? entry.filename)) : new Set())} /> +
{ event.stopPropagation(); toggleSelect(name); }}> + toggleSelect(name)} onClick={(event) => event.stopPropagation()} /> + +
+ + {isDir ? ( + + ) : ( + {name} + )} +
+
{isDir ? '—' : formatBytes(entry.size ?? null)}{formatDate(entry.modified ?? entry.lastModified ?? null)}
+ )} +
- + + {ctxMenu && (() => { + const { x, y, entry, childPath } = ctxMenu; + const isDir = entry ? (entry.type === 'directory' || entry.isDir) : false; + const name = entry ? (entry.name ?? entry.filename ?? '') : ''; + const isPinned = entry ? pins.some((pinItem) => pinItem.path === childPath) : false; + const clipboardItems = entry + ? (selected.has(name) + ? [...selected].map((n) => { + const e = entries.find((en) => (en.name ?? en.filename) === n); + return { name: n, path: e?.path ?? (path === '/' ? `/${n}` : `${path}/${n}`) }; + }) + : [{ name, path: childPath }]) + : []; + const itemStyle = { + display: 'flex', + alignItems: 'center', + gap: 8, + padding: '7px 14px', + fontSize: 13, + cursor: 'pointer', + color: 'var(--text-primary)', + background: 'none', + border: 'none', + width: '100%', + textAlign: 'left', + borderRadius: 4, + whiteSpace: 'nowrap', + }; + const separatorStyle = { height: 1, background: 'var(--border)', margin: '4px 0' }; + + return ( +
event.stopPropagation()} style={{ position: 'fixed', left: x, top: y, zIndex: 1000, background: 'rgba(20,20,20,0.96)', border: '1px solid var(--border)', borderRadius: 8, backdropFilter: 'blur(16px)', padding: '4px 0', minWidth: 160, boxShadow: '0 8px 24px rgba(0,0,0,0.4)' }}> + {entry && isDir && ( + + )} + + {entry && !isDir && ( + + )} + + {entry && isDir && ( + <> +
+ + + )} + + {entry && ( + <> +
+ + + + )} + + {clipboard && ( + <> +
+ + + )} + + {isTeam && entry && ( + <> +
+ + + )} +
+ ); + })()} ); } diff --git a/src/pages/Login.jsx b/src/pages/Login.jsx old mode 100755 new mode 100644 diff --git a/src/pages/ProjectDetailPage.jsx b/src/pages/ProjectDetailPage.jsx index db08d6f..0fe3b94 100644 --- a/src/pages/ProjectDetailPage.jsx +++ b/src/pages/ProjectDetailPage.jsx @@ -2,16 +2,15 @@ import { useState, useEffect } from 'react'; import { useParams, Link, useNavigate } from 'react-router-dom'; import Layout from '../components/Layout'; import StatusBadge from '../components/StatusBadge'; -import FileBrowser from '../components/FileBrowser'; import SortTh from '../components/SortTh'; import { supabase } from '../lib/supabase'; import { useAuth } from '../context/AuthContext'; import { serviceTypes } from '../data/mockData'; import { addDaysToDateOnly, getTodayDateOnlyEST } from '../lib/dates'; import { logActivity } from '../lib/activityLog'; +import { createTaskFolder } from '../lib/filebrowserFolders'; 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 emptyJobForm = () => ({ title: '', serviceType: '', deadline: addDaysToDateOnly(getTodayDateOnlyEST(), 3), description: '', requestedBy: '', isHot: false }); @@ -149,6 +148,7 @@ export default function ProjectDetailPage() { 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)', '') }); 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]); setJobForm(emptyJobForm()); setShowAddJob(false); @@ -315,20 +315,6 @@ export default function ProjectDetailPage() {
)} - {/* 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 ( -
-
Project Files
- -
- ); - })()} {/* Client: mine/all filter */} {isClient && ( diff --git a/src/pages/RequestDetail.jsx b/src/pages/RequestDetail.jsx index 4d455b1..4f3bce5 100644 --- a/src/pages/RequestDetail.jsx +++ b/src/pages/RequestDetail.jsx @@ -10,7 +10,7 @@ import { sendEmail } from '../lib/email'; import { useAuth } from '../context/AuthContext'; import { serviceTypes } from '../data/mockData'; import { addDaysToDateOnly, formatDateEST, getTodayDateOnlyEST } from '../lib/dates'; -import { uploadFilesToRequestInfo } from '../lib/filebrowserFolders'; +import { uploadFilesToRequestInfo, createProjectFolder } from '../lib/filebrowserFolders'; import { logActivity } from '../lib/activityLog'; const rLabel = (v) => 'R' + String(v || 0).padStart(2, '0'); @@ -426,6 +426,7 @@ export default function RequestDetail() { try { 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); + createProjectFolder(company.name, newProj.name).catch(() => {}); await supabase.from('tasks').update({ project_id: newProj.id }).eq('id', id); setTask(t => ({ ...t, project_id: newProj.id })); setProject(p => ({ ...p, id: newProj.id, name: newProj.name })); diff --git a/src/pages/RequestsPage.jsx b/src/pages/RequestsPage.jsx index 7562505..52fedc9 100644 --- a/src/pages/RequestsPage.jsx +++ b/src/pages/RequestsPage.jsx @@ -12,7 +12,7 @@ import { getCurrentVersionForTask, getDeadlineSourceSubmission } from '../lib/ta import { formatDateOnly, fmtShortDate } from '../lib/dates'; import { createInitialSubmissionForRequest, createTaskForRequest, findOrCreateProject } from '../lib/requestSubmission'; import { sendEmail } from '../lib/email'; -import { uploadFilesToRequestInfo } from '../lib/filebrowserFolders'; +import { uploadFilesToRequestInfo, createTaskFolder } from '../lib/filebrowserFolders'; import SortTh from '../components/SortTh'; import { useSortable } from '../hooks/useSortable'; 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]); const { task } = await createTaskForRequest({ projectId: resolvedProject.id, title: formData.title.trim(), requestKey: addRequestKey }); 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({ taskId: task.id, requestKey: addRequestKey, isHot: formData.isHot, 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 { task } = await createTaskForRequest({ projectId: resolvedProject.id, title: formData.title.trim(), requestKey: addRequestKey }); if (!task) throw new Error('Failed to create task.'); + createTaskFolder(selectedCompany?.name, resolvedProject.name, formData.title.trim()).catch(() => {}); const { submission } = await createInitialSubmissionForRequest({ taskId: task.id, requestKey: addRequestKey, isHot: formData.isHot, serviceType: formData.serviceType, deadline: formData.deadline, diff --git a/src/pages/Settings.jsx b/src/pages/Settings.jsx old mode 100755 new mode 100644 index 99ceb0e..f0d0428 --- a/src/pages/Settings.jsx +++ b/src/pages/Settings.jsx @@ -1,8 +1,49 @@ import { useEffect, useMemo, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import Layout from '../components/Layout'; +import SortTh from '../components/SortTh'; import { supabase } from '../lib/supabase'; 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 ( + + + + + + + + + + + ); +} + +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() { const { currentUser } = useAuth(); @@ -18,15 +59,15 @@ export default function ProfilePage() { const [editError, setEditError] = useState(''); const [editForm, setEditForm] = useState({ name: '', - title: '', + email: '', website: '', linkedin: '', - instagram: '', - twitter: '', }); + const [avatarUploading, setAvatarUploading] = useState(false); const [activityItems, setActivityItems] = useState([]); const [profileStats, setProfileStats] = useState(null); + const [profileTaskItems, setProfileTaskItems] = useState([]); const isSelfView = !profileId || profileId === currentUser?.id; @@ -43,17 +84,8 @@ export default function ProfilePage() { setLoadingProfile(true); setProfileError(''); try { - const [{ data: profile, error }, { data: assignedTasks }] = await Promise.all([ - supabase - .from('profiles') - .select('*') - .eq('id', profileId) - .single(), - supabase - .from('tasks') - .select('id, title, deadline, status') - .eq('assigned_to', profileId) - .not('deadline', 'is', null), + const [{ data: profile, error }] = await Promise.all([ + supabase.from('profiles').select('*').eq('id', profileId).single(), ]); if (error || !profile) { setProfileError('Unable to load profile.'); @@ -82,16 +114,6 @@ export default function ProfilePage() { setViewedProfile(profile); setViewedCompanies([...names]); 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) { if (!cancelled) setProfileError('Unable to load profile.'); } finally { @@ -110,24 +132,49 @@ export default function ProfilePage() { const uid = isSelfView ? currentUser?.id : profileId; if (!uid) return; 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([ 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('tasks').select('id', { count: 'exact', head: true }).eq('assigned_to', uid).eq('status', 'revision_requested'), - supabase.from('submissions').select('id', { count: 'exact', head: true }).eq('submitted_by', uid), - ]).then(([completed, members, revisions, submissions]) => { + supabase.from('tasks').select('project_id, submitted_at').eq('assigned_to', uid).not('project_id', 'is', null).gte('submitted_at', monthStart.toISOString()), + ]).then(([completedTotal, completedRecent, members, tasksByMonth]) => { 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 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")') : Promise.resolve({ count: 0 }); - fetchActiveProjects.then(active => { + fetchActive.then(active => { if (cancelled) return; setProfileStats({ - tasksCompleted: completed.count ?? 0, + tasksCompleted: completedTotal.count ?? 0, + tasksChart: weekBuckets, activeProjects: active.count ?? 0, - revisionRequests: revisions.count ?? 0, - submissions: submissions.count ?? 0, + projectsChart: monthBuckets, }); }); }); @@ -138,23 +185,69 @@ export default function ProfilePage() { let cancelled = false; const uid = isSelfView ? currentUser?.id : profileId; if (!uid) return; - supabase - .from('activity_log') - .select('id, created_at, action, task_id, task_title, project_name, project_id') - .eq('actor_id', uid) - .order('created_at', { ascending: false }) - .limit(10) - .then(({ data }) => { - if (cancelled) return; - setActivityItems(data || []); - }); + + 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 + .from('activity_log') + .select('id, created_at, action, task_id, task_title, project_name, project_id') + .eq('actor_id', uid) + .order('created_at', { ascending: false }) + .limit(20), + 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; + 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; }; }, [isSelfView, currentUser?.id, profileId]); const profile = useMemo( () => isSelfView ? { ...(currentUser || {}), ...(viewedProfile || {}) } : viewedProfile, // 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(() => { if (isSelfView) { @@ -168,6 +261,12 @@ export default function ProfilePage() { if (isSelfView) return currentUser?.company?.address || currentUser?.companies?.[0]?.company?.address || ''; return 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 d = profile?.created_at ? new Date(profile.created_at) : null; @@ -176,18 +275,23 @@ export default function ProfilePage() { : null; }, [profile?.created_at]); - const socialLinks = useMemo(() => { - if (!profile) return []; - const candidates = [ - { key: 'website', label: 'Website' }, - { key: 'linkedin', label: 'LinkedIn' }, - { key: 'instagram', label: 'Instagram' }, - { key: 'twitter', label: 'X / Twitter' }, - ]; - return candidates - .map(({ key, label }) => ({ label, value: profile[key] })) - .filter((item) => typeof item.value === 'string' && item.value.trim().length > 0); - }, [profile]); + const handleAvatarUpload = async (e) => { + const file = e.target.files?.[0]; + if (!file || !currentUser?.id) return; + setAvatarUploading(true); + try { + const ext = file.name.split('.').pop(); + const path = `${currentUser.id}/avatar.${ext}`; + const { error: upErr } = await supabase.storage.from('avatars').upload(path, file, { upsert: true }); + if (upErr) { setEditError(upErr.message); return; } + const { data: { publicUrl } } = supabase.storage.from('avatars').getPublicUrl(path); + const { data, error: dbErr } = await supabase.from('profiles').update({ avatar_url: publicUrl }).eq('id', currentUser.id).select('*').single(); + if (dbErr) { setEditError(dbErr.message); return; } + setViewedProfile(data); + } finally { + setAvatarUploading(false); + } + }; const dashCardStyle = { background: 'var(--card-bg)', border: '1px solid var(--border)', @@ -208,16 +312,14 @@ export default function ProfilePage() { if (!profile) return; setEditForm({ name: profile.name || '', - title: profile.title || '', + email: profile.email || '', website: profile.website || '', linkedin: profile.linkedin || '', - instagram: profile.instagram || '', - twitter: profile.twitter || '', }); setEditError(''); // 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 })); @@ -229,12 +331,10 @@ export default function ProfilePage() { try { const payload = { name: editForm.name.trim(), - title: editForm.title.trim(), + email: editForm.email.trim(), website: editForm.website.trim(), linkedin: editForm.linkedin.trim(), - instagram: editForm.instagram.trim(), - twitter: editForm.twitter.trim(), }; const { data, error } = await supabase .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: }, + task_resumed: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: }, + task_submitted: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: <> }, + task_approved: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: }, + task_on_hold: { bg: 'rgba(239,68,68,0.15)', color: '#ef4444', path: <> }, + task_created: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: <> }, + project_created: { bg: 'rgba(245,165,35,0.15)', color: '#F5A523', path: <> }, + request_submitted: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: <> }, + revision_requested: { bg: 'rgba(245,158,11,0.15)', color: '#f59e0b', path: <> }, + }; + 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: }; + return ( +
+ {cfg.path} +
+ ); + }; + const ACTION_LABEL = { - task_created: 'created', task_started: 'started', task_on_hold: 'put on hold', - task_resumed: 'resumed', task_submitted: 'submitted', task_approved: 'approved', - project_created: 'created project', request_submitted: 'submitted', revision_requested: 'requested revision on', + task_created: 'Task created', task_started: 'Task started', task_on_hold: 'Task put on hold', + task_resumed: 'Task resumed', task_submitted: 'Task submitted', task_approved: 'Task approved', + 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 ( +
+
0 ? 14 : 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}> + Tasks In Progress + {rows.length > 5 && ( + + )} +
+ {rows.length === 0 ? ( +
No tasks in progress
+ ) : ( + + + + + + + + + Task + + + Status + + + + + {visibleRows.map((task) => ( + + + + + ))} + +
+ + + {formatTaskStatus(task.status)} +
+ )} +
+ ); }; const ProfileActivityFeed = ({ items }) => ( @@ -269,8 +470,9 @@ export default function ProfilePage() {
No recent activity
) : items.map((e, i) => (
0 ? 10 : 0 }}> +
- {ACTION_LABEL[e.action] || e.action} + {toSentenceTitle(ACTION_LABEL[e.action] || e.action)} {e.task_title && e.task_id && ( <> @@ -294,89 +496,114 @@ export default function ProfilePage() {
- {isSelfView && ( -
+
+ {isSelfView && ( -
- {memberSince && ( -
-
Member Since
-
{memberSince}
+ )} +
+ {memberSince && ( +
+
Member Since
+
{memberSince}
+
+ )} + {profile?.role && ( +
+
Role
+
+ {profile.role === 'team' ? 'Team' : profile.role === 'external' ? 'Subcontractor' : profile.role === 'client' ? 'Client' : profile.role}
- )} - {profile?.role && ( -
-
Role
-
{profile.role}
-
- )} -
+
+ )}
- )} -
- {initials || '?'} +
+
+ {profile?.avatar_url + ? {profile.name} + : (initials || '?')}
-
{profile?.name || '—'}
- {profile?.title &&
{profile.title}
} -
- {companyNames.length > 0 ? companyNames.join(', ') : '—'} +
{profile?.name || '—'}
+
+ {companyDisplayName}
{companyAddress && (
- - {companyAddress} + + {companyAddress}
)} {profile?.email && (
- - {profile.email} + + {profile.email} +
+ )} + {profile?.website && ( + )} {profile?.linkedin && ( )}
{profileStats && ( -
- {[ - { label: 'Tasks Completed', value: profileStats.tasksCompleted, iconBg: 'rgba(74,222,128,0.15)', iconColor: '#4ade80', iconPath: '' }, - { label: 'Active Projects', value: profileStats.activeProjects, iconBg: 'rgba(245,165,35,0.15)', iconColor: '#F5A523', iconPath: '' }, - { label: 'Revision Requests',value: profileStats.revisionRequests, iconBg: 'rgba(239,68,68,0.15)', iconColor: '#f87171', iconPath: '' }, - { label: 'Submissions', value: profileStats.submissions, iconBg: 'rgba(96,165,250,0.15)', iconColor: '#60a5fa', iconPath: '' }, - ].map(({ label, value, iconBg, iconColor, iconPath }) => ( -
-
-
{label}
-
-
{value}
-
-
-
-
- -
+
+ {/* Tasks Completed — weekly */} +
+
+
+
Tasks Completed
+
+
{profileStats.tasksCompleted}
- ))} +
+
+ ' }} /> +
+
+
+ +
+ {/* Active Projects — monthly */} +
+
+
+
Active Projects
+
+
{profileStats.activeProjects}
+
+
+
+
+ ' }} /> +
+
+
+ +
)}
- +
+ + +
)}
@@ -399,48 +626,53 @@ export default function ProfilePage() {
e.stopPropagation()} > -
Edit Profile
+
Edit Profile
-
- - +
+
+ {profile?.avatar_url + ? + : (initials || '?')} +
+
+ + +
JPG, PNG or WebP
+
- - + + +
+ +
+ +
- - + +
- - -
-
- - -
-
- - -
-
- - + +
{editError &&
{editError}
}
- -
diff --git a/src/pages/client/NewRequest.jsx b/src/pages/client/NewRequest.jsx old mode 100755 new mode 100644 diff --git a/src/pages/team/CreateInvoice.jsx b/src/pages/team/CreateInvoice.jsx index 7d84724..abb739b 100644 --- a/src/pages/team/CreateInvoice.jsx +++ b/src/pages/team/CreateInvoice.jsx @@ -29,6 +29,12 @@ const buildRevisionItemDescription = (revision) => { 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() { const navigate = useNavigate(); const { currentUser } = useAuth(); @@ -90,23 +96,22 @@ export default function CreateInvoice() { setInvoiceEmail(recipients[0]?.email || companies.find(c => c.id === selectedCompanyId)?.contact_email || ''); if (projects && projects.length > 0) { const projectIds = projects.map(p => p.id); - const [{ data: tasks }, { data: revisions }] = await Promise.all([ - supabase - .from('tasks') - .select('*, project:projects(name), submissions(service_type, type, version_number)') - .in('project_id', projectIds) - .eq('invoiced', false) - .eq('status', 'client_approved'), - supabase + const { data: tasks } = await supabase + .from('tasks') + .select('*, project:projects(name), submissions(service_type, type, version_number)') + .in('project_id', projectIds) + .eq('invoiced', false) + .eq('status', 'client_approved'); + const approvedTaskIds = (tasks || []).map((t) => t.id).filter(Boolean); + const { data: revisions } = approvedTaskIds.length > 0 + ? await supabase .from('submissions') .select('*, task:tasks(id, title, project:projects(name), submissions(service_type, type))') .eq('type', 'revision') .or('revision_type.eq.client_revision,revision_type.is.null') .eq('invoiced', false) - .in('task_id', - (await supabase.from('tasks').select('id').in('project_id', projectIds)).data?.map(t => t.id) || [] - ), - ]); + .in('task_id', approvedTaskIds) + : { data: [] }; const tasksWithService = (tasks || []).map(t => { const initial = (t.submissions || []).find(s => s.type === 'initial') || (t.submissions || [])[0]; return { ...t, service_type: initial?.service_type || t.title }; @@ -141,11 +146,14 @@ export default function CreateInvoice() { const serviceLabel = getRevisionServiceType(revision); const description = buildRevisionItemDescription(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 => { 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)]; }); }; diff --git a/src/pages/team/TeamDashboard.jsx b/src/pages/team/TeamDashboard.jsx index 7ea7a26..9367f62 100644 --- a/src/pages/team/TeamDashboard.jsx +++ b/src/pages/team/TeamDashboard.jsx @@ -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 ( +
+
0 ? 14 : 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}> + Tasks In Progress + {rows.length > 5 && ( + + )} +
+ {rows.length === 0 ? ( +
No tasks in progress
+ ) : ( + + + + + + + + Task + Assigned To + + + + {visibleRows.map((t) => ( + + + + + ))} + +
+ + + {t.assigned_to + ? + : (t.assigned_name || '—')} +
+ )} +
+ ); +} + function HotItemsCard({ submissions, tasks }) { const navigate = useNavigate(); const { sortKey, sortDir, toggle, sort } = useSortable('deadline'); @@ -344,10 +398,10 @@ function HotItemsCard({ submissions, tasks }) { return (
0 ? 14 : 0, flexShrink: 0 }}> - Hot Items + Hot Tasks
{hotItems.length === 0 ? ( -
No hot items
+
No hot tasks
) : ( @@ -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 allMonthOpts = useMemo(() => { const opts = []; @@ -495,7 +555,7 @@ function TeamPerformanceCard({ tasks }) { const d = new Date(t.completed_at); if (d.getFullYear() !== year || d.getMonth() + 1 !== month) 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; else entry.revCount += 1; map.set(t.assigned_name, entry); @@ -524,10 +584,10 @@ function TeamPerformanceCard({ tasks }) { const tone = iconTone(p.name); return (
0 ? 10 : 0 }}> - + {(() => { const prof = p.id ? profileMap.get(p.id) : null; const tone = iconTone(p.name); return prof?.avatar_url ?
{p.name}
: ; })()}
- {p.name} + {p.id ? : {p.name}} {p.newCount} new · {p.revCount} revision
@@ -562,6 +622,7 @@ export default function TeamDashboard() { const [clientProfiles, setClientProfiles] = useState(() => cached?.clientProfiles || []); const [companyMemberships, setCompanyMemberships] = useState(() => cached?.companyMemberships || []); const [activityLog, setActivityLog] = useState(() => cached?.activityLog || []); + const [allProfiles, setAllProfiles] = useState(() => cached?.allProfiles || []); const [teamInvoices, setTeamInvoices] = useState(() => cached?.teamInvoices || []); const [teamExpenses, setTeamExpenses] = useState([]); 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('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('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('companies').select('id, name').order('name'), supabase.from('company_members').select('company_id, profile_id'), @@ -613,6 +674,7 @@ export default function TeamDashboard() { setProjects(p || []); setSubmissions(subsWithRole); setClientProfiles(clients); + setAllProfiles(profiles || []); setAllCompanies(cos || []); setCompanyMemberships(memRows || []); setActivityLog(activity || []); @@ -662,6 +724,7 @@ export default function TeamDashboard() { if (loading) return

Loading...

; 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 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)); @@ -696,13 +759,14 @@ export default function TeamDashboard() {
-
+
+
- +
diff --git a/supabase/functions/fbq-backfill/index.ts b/supabase/functions/fbq-backfill/index.ts new file mode 100644 index 0000000..8b03a2b --- /dev/null +++ b/supabase/functions/fbq-backfill/index.ts @@ -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 { + 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 = { 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(items: T[], fn: (item: T) => Promise, 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' }, + }); +}); diff --git a/supabase/functions/fbq-proxy/index.ts b/supabase/functions/fbq-proxy/index.ts new file mode 100644 index 0000000..8bd3a50 --- /dev/null +++ b/supabase/functions/fbq-proxy/index.ts @@ -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, userId: string): Promise { + 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(); + 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 { + 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); +}); diff --git a/supabase/functions/send-email/index.ts b/supabase/functions/send-email/index.ts old mode 100755 new mode 100644 diff --git a/supabase/migrations/20260528000001_add_avatar_to_profiles.sql b/supabase/migrations/20260528000001_add_avatar_to_profiles.sql new file mode 100644 index 0000000..8ec2096 --- /dev/null +++ b/supabase/migrations/20260528000001_add_avatar_to_profiles.sql @@ -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 $$; diff --git a/supabase/migrations/20260528000002_add_social_fields_to_profiles.sql b/supabase/migrations/20260528000002_add_social_fields_to_profiles.sql new file mode 100644 index 0000000..43b28c8 --- /dev/null +++ b/supabase/migrations/20260528000002_add_social_fields_to_profiles.sql @@ -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; diff --git a/supabase/migrations/20260528000003_drop_instagram_twitter_from_profiles.sql b/supabase/migrations/20260528000003_drop_instagram_twitter_from_profiles.sql new file mode 100644 index 0000000..fb8a802 --- /dev/null +++ b/supabase/migrations/20260528000003_drop_instagram_twitter_from_profiles.sql @@ -0,0 +1,3 @@ +alter table public.profiles + drop column if exists instagram, + drop column if exists twitter; diff --git a/supabase/migrations/20260528000004_drop_title_from_profiles.sql b/supabase/migrations/20260528000004_drop_title_from_profiles.sql new file mode 100644 index 0000000..28e2f54 --- /dev/null +++ b/supabase/migrations/20260528000004_drop_title_from_profiles.sql @@ -0,0 +1,2 @@ +alter table public.profiles + drop column if exists title; diff --git a/supabase/schema.sql b/supabase/schema.sql old mode 100755 new mode 100644 diff --git a/vite.config.js b/vite.config.js old mode 100755 new mode 100644