Compare commits

..

11 Commits

Author SHA1 Message Date
Krao Hasanee 26ed7790b1 Merge branch 'fix/folder-reconcile' 2026-07-21 14:21:29 -04:00
Krao Hasanee 4c78b624f8 feat: add folder reconcile endpoint to backfill missing FileBrowser folders
Folder sync failed silently whenever FileBrowser was unreachable: every
call site swallows the error with console.error, so projects and tasks
were created in the portal with no matching folder on the drive.

Add api/folder-reconcile.js, a secret-protected endpoint that walks
companies/projects/tasks/subcontractors and creates only the folders that
are missing. It never deletes or renames. Folders on disk with no portal
record are reported as orphans for manual review, since they predate the
portal and hold real client work.

Extract shared FileBrowser helpers into api/_lib/filebrowser.js. Note that
this build returns directory listings under a `folders` key rather than
`items`; reading the wrong key yields an empty list and makes the whole
tree look missing, so listDirectories handles both shapes.

Reconcile lists before writing and creates leaves directly rather than
re-walking each path from the root, which keeps a full run at a few
seconds instead of timing out at the 300s function limit.

Schedule hourly via pg_cron: Vercel Hobby caps crons at once per day.
Requires app.folder_reconcile_secret to be set on the database separately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 14:18:53 -04:00
Krao Hasanee 2cfbbec9dc cleanup: remove dead code, drop unused deps, refactor derived-state effects
Lint went from 84 problems to 0. Build passes.

Dead code:
- TeamInvoices: drop the orphaned Subcontractor PO block (updateSubcontractorPO
  plus send/ready-to-pay/paid/reopen/cancel/delete/download/CPA-export handlers
  and ~25 derived vars). That feature moved to the routed
  TeamCreateSubcontractorPO / TeamSubcontractorPODetail pages in the Layout2
  redesign; these were leftovers. 2100 -> 1880 lines.
- Tasks: drop unrendered project sort/tab/completion logic in TasksPageShell.
- Remove 59 unused vars, imports, and handlers across 12 other files.

Removed wasted queries:
- TeamInvoices ran a nested subcontractor_payments join (profiles, projects,
  companies, PO items, tasks) on every page load into state nothing read.
- TeamInvoices and ExternalMyInvoices each fetched a task-type map on invoice
  open and discarded it; the ExternalMyInvoices one blocked the detail popup.
- RequestForm queried sign_families on mount into unread state.

Dependencies:
- Drop heic2any (zero imports). heic-to is the one in use and already lazy-loads.

Effects and exports:
- RequestForm: remove 4 effects. Sign-row sizing and company-change resets move
  into their change handlers; single-company selection is derived during render
  rather than written back into form state.
- FilterDropdown: measure position on click instead of in an effect, which also
  removes a one-frame stale-position flicker.
- BrandBook: latest-ref pattern so the 900ms address debounce is not reset by
  handler identity.
- ClientMyInvoices: hoist MONTHS to module scope.
- Split non-component exports: POPUP_FIELD_LABEL moves to lib/popupStyles.js;
  resolveProfileAvatar and useLiveClock are internal-only; getGreeting deleted
  as nothing referenced it.

Companies.jsx keeps one scoped eslint-disable with a reason: load() only
setStates after awaiting its queries, so set-state-in-effect is a false positive
there, and load() is shared with the create/delete handlers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 15:13:01 -04:00
Krao Hasanee 1f52b6e96c fix: sub billing only applies to work an external sub delivered
Report was flagging every unbilled delivered version as "Not Invoiced"
for sub billing, even when a team member delivered it — team work is
billed to the client directly and is never sub-invoiced. Now traces
each version's actual deliverer and shows N/A when it's a team member,
regardless of who delivered earlier versions on the same task.

Also: SubcontractorInvoiceForm no longer masks fetch errors as "No
completed tasks available to invoice" — the real error now shows in
the Completed Tasks panel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 20:15:04 -04:00
Krao Hasanee 2c1ff61b29 fix: popups no longer close on backdrop click, only via explicit close/cancel
Clicking the dimmed area outside a modal (+Task, +Invoice, expense,
review, etc.) accidentally dismissed it and lost in-progress input.
Every popup already has an explicit Cancel/Close/✕ button, so the
backdrop click handler is removed; also drops the now-dead
onClose/blockClose prop plumbing that only existed to support it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 16:45:37 -04:00
Krao Hasanee bf70646a54 fix: sub invoice eligibility follows delivery author, not task assignment
Completed Tasks list scoped by task.assigned_to meant a reassigned task
made the original sub's already-delivered versions permanently unbillable
by anyone. Now scoped by deliveries.sent_by directly, so each version
stays billable by whoever actually delivered it, independent of who
currently owns the task.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 16:04:24 -04:00
Krao Hasanee 0d6ac3666e fix: revert leftover Project→Client mislabels, drop tracked .env.backfill
Rename in 91045a1 was only partially reverted by later commits, leaving
~20 UI spots calling the project entity "Client" while table headers
already said "Project". Reverted all of them plus the /clients/:id
route (now /projects/:id canonical again, nothing was live yet).

Also: stop tracking .env.backfill (had a live admin password/token
committed - rotate those credentials), remove stale pre-redesign
layout.md superseded by REDESIGN-LAYOUT2.md.
2026-07-15 15:14:57 -04:00
Krao Hasanee 567c941151 rename: task column "Project Name" → "Project"; request form field → "Project / Location"
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 10:54:05 -04:00
Krao Hasanee 6d8aa6bda4 rename: task "Name" column → "Project Name"; request form field → "Project Name / Location / Title"
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 10:51:05 -04:00
Krao Hasanee bcdb177244 fix: report billing counts only sent/paid invoices as Invoiced
Draft invoices and orphaned invoice line items (invoice deleted → null)
no longer count as Invoiced on the billing report, matching what the
Finances page shows as issued. Not-invoiced/invoiced/paid buckets still
sum to total version rows. No-op on current data (no drafts/orphans);
guards future drafts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 10:17:19 -04:00
Krao Hasanee 91045a1897 redesign: rename Project→Client, finance single-card + filters, mobile tasks, sticky headers
- Rename "Project" → "Client" across UI (labels + /projects→/clients routes with redirect); normalize company-meaning labels to "Company"
- Finance tabs: merge dual cards into one per tab, add header column filters (expenses/subs/invoices), overview as 3-section card, move +Expense/+Invoice to card right edge
- Tasks: responsive mobile stats grid, progressive column hiding (min Status+Name+Assigned), sidebar mobile footer (profile/theme/signout)
- Hide Company column for single-company users; +Task shows disabled Company field instead of hiding
- Sticky table headers site-wide with opaque card background
- Pin dev server to port 5173

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 09:39:54 -04:00
37 changed files with 973 additions and 1565 deletions
-35
View File
@@ -1,35 +0,0 @@
# Created by Vercel CLI
FILEBROWSER_ADMIN_PASS="ChloeH092524#"
FILEBROWSER_ADMIN_USER="admin"
FILEBROWSER_SUBS_ROOT="/fourgebranding/team"
FILEBROWSER_TEAM_ROOT="/fourgebranding"
FILEBROWSER_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJGaWxlQnJvd3NlciBRdWFudHVtIiwiZXhwIjo2NTc1NDk4NDMzLCJpYXQiOjE3NzkwNzAxMjYsImJlbG9uZ3NUbyI6MSwiUGVybWlzc2lvbnMiOnsiYXBpIjp0cnVlLCJhZG1pbiI6dHJ1ZSwibW9kaWZ5Ijp0cnVlLCJzaGFyZSI6dHJ1ZSwicmVhbHRpbWUiOnRydWUsImRlbGV0ZSI6dHJ1ZSwiY3JlYXRlIjp0cnVlLCJkb3dubG9hZCI6dHJ1ZX19.sQzImZQMlvbKpDWdnN9ksehmkNG8wy6SpnjgZ1uFC2c"
FILEBROWSER_URL="https://fourgebranding.krao.us"
NX_DAEMON="false"
PASSWORD_VAULT_KEY=""
SUPABASE_SERVICE_ROLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImZxZmx4eHF2ZW5uaHZvZXl3cmR3Iiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc3NDA0NzEzNCwiZXhwIjoyMDg5NjIzMTM0fQ.OPI-XtZDI0x83Lu5HaUl-YZx2EFAjFtHDivKx1_DXxA"
SUPABASE_WEBHOOK_SECRET="c76769359f53e7fb920776dd895f38b243cfdab4e2f07740"
TURBO_CACHE="remote:rw"
TURBO_DOWNLOAD_LOCAL_ENABLED="true"
TURBO_REMOTE_ONLY="true"
TURBO_RUN_SUMMARY="true"
VERCEL="1"
VERCEL_ENV="production"
VERCEL_GIT_COMMIT_AUTHOR_LOGIN=""
VERCEL_GIT_COMMIT_AUTHOR_NAME=""
VERCEL_GIT_COMMIT_MESSAGE=""
VERCEL_GIT_COMMIT_REF=""
VERCEL_GIT_COMMIT_SHA=""
VERCEL_GIT_PREVIOUS_SHA=""
VERCEL_GIT_PROVIDER=""
VERCEL_GIT_PULL_REQUEST_ID=""
VERCEL_GIT_REPO_ID=""
VERCEL_GIT_REPO_OWNER=""
VERCEL_GIT_REPO_SLUG=""
VERCEL_OIDC_TOKEN="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Im1yay00MzAyZWMxYjY3MGY0OGE5OGFkNjFkYWRlNGEyM2JlNyJ9.eyJpc3MiOiJodHRwczovL29pZGMudmVyY2VsLmNvbS9rcmFvIiwic3ViIjoib3duZXI6a3Jhbzpwcm9qZWN0OmZvdXJnZS1wb3J0YWw6ZW52aXJvbm1lbnQ6ZGV2ZWxvcG1lbnQiLCJzY29wZSI6Im93bmVyOmtyYW86cHJvamVjdDpmb3VyZ2UtcG9ydGFsOmVudmlyb25tZW50OmRldmVsb3BtZW50IiwiYXVkIjoiaHR0cHM6Ly92ZXJjZWwuY29tL2tyYW8iLCJvd25lciI6ImtyYW8iLCJvd25lcl9pZCI6InRlYW1fR01WaUJ3N2xWYVpjVG9ST21RelFaeExyIiwicHJvamVjdCI6ImZvdXJnZS1wb3J0YWwiLCJwcm9qZWN0X2lkIjoicHJqX0RtUjdoZXhHdWoydTFiaHUzUlRpaGNVQWFwb0giLCJlbnZpcm9ubWVudCI6ImRldmVsb3BtZW50IiwicGxhbiI6ImhvYmJ5IiwidXNlcl9pZCI6InR3Q3dFWkI2MHZFelpma1pjWnREQ0VFbSIsImNsaWVudF9pZCI6ImNsX0hZeU9QQk50Rk1mSGhhVW45TDRRUGZUWno2VFA0N2JwIiwibmJmIjoxNzc5MDc0MTYyLCJpYXQiOjE3NzkwNzQxNjIsImV4cCI6MTc3OTExNzM2Mn0.Exb_7yIDR4iFQUEjRm2EpGRNHYug3Ixb7kcrga2Wj4DMsVY9Cm7AnC7wwe87e5SkA_qaKzVw6jR0w_obBAXPcZYQdnGhE4uAwz5LX4fVDTys-jBAzCp6AMNLmjnCjFBMfsB1UkO7g7OX7z9rhkvhLa8HVrt47Ulg5f5BN6E571ob23hkJJFP9fz5NzSzq-jXb-cyXfkcjQAWdU0Lw-NpuaoeUNr-VZhIc17rfQ6gwT-57UWi47Yikvn4bElVHAYT_UVtqPcph3LN9UNPRCtOwh0VQxzi-RiamW3maEmCdxsDv88DwY9Xj_jn79I_MIcmOUBvQGLjArkj-cxvFEwgqg"
VERCEL_TARGET_ENV="production"
VERCEL_TOKEN=""
VERCEL_URL=""
VITE_MAPBOX_TOKEN="pk.eyJ1Ijoia3Jhb2ZvdXJnZSIsImEiOiJjbW9hdHlyM2EwYW84MnBwemx1ZjRqYzY2In0.afcwBOBqUBnJqn9zIvZShQ"
VITE_SUPABASE_ANON_KEY="sb_publishable_qNNIKtnu1dUIVKelq9aYYQ_TfHgzhyR"
VITE_SUPABASE_URL="https://fqflxxqvennhvoeywrdw.supabase.co"
+1
View File
@@ -11,6 +11,7 @@ node_modules
dist
dist-ssr
*.local
.env.backfill
# Editor directories and files
.vscode/*
+1
View File
@@ -191,3 +191,4 @@ Legend: ⬜ todo · 🔄 in progress · ✅ done
- 2026-06-23 — Popups single-source: `--popup-bg` now = `var(--card-bg)` (dark); stripped 8 inline `background: var(--card-bg)` overrides on `popupSurfaceStyle` so loader/modals/menus all use the token. Light stays opaque white.
- 2026-06-23 — Dashboard reflow: row2 = To Do (fluid) + Calendar (280px); row3 = Activity / In-Progress / Team Perf. New `.dash-row-todo` / `.dash-row-trio` classes; responsive (trio 2-up ≤1200, all stack ≤768). Retired bottomCardsHeight viewport-fill hack. Mobile-friendly is now a standing requirement for Layout2.
- 2026-06-23 — Perf sweep: root cause = ~400770ms/query server cold-start (compute tier, infra). Code fixes: added FK/filter index migration (pushed to DB); removed redundant client/external scope waterfall in Tasks + Dashboard (rely on RLS, verified `has_company_access`/`project_members` parity); embedded submitter-profiles + deliveries into the Tasks submissions query (2 round trips all roles). Test client/external dashboards on 5173.
- 2026-07-15 — Naming cleanup: the 91045a1 Project→Client rename was only partially walked back by later commits, leaving the project entity mislabeled "Client" in ~20 spots (Add Project modal, filters, PO/report/dashboard columns, etc.) while table headers already said "Project". Confirmed with user: **Company** = the customer org (e.g. "Public Storage"), **Project** = a job within that company — "Client" was a leftover label, not a third entity. Reverted all mislabeled text to "Project"; reverted the `/clients/:id` route back to canonical `/projects/:id` (nothing deployed to prod yet, so no live URLs broke). Also: removed stale pre-redesign `layout.md` (superseded by this doc), stopped tracking `.env.backfill` (had a live admin password/token committed — rotate those credentials separately).
+143
View File
@@ -0,0 +1,143 @@
import { createClient } from '@supabase/supabase-js';
export const FB_SOURCE = 'srv';
export const PROJECT_DEFAULT_SUBFOLDERS = ['00 Project Files'];
export const REQUEST_DEFAULT_SUBFOLDERS = ['Old Books', 'Working Files', 'Survey'];
export 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: path traversal not allowed');
clean.push(part);
}
return `/${clean.join('/')}`;
}
export function joinPath(...parts) {
return normalizePath(parts.join('/'));
}
export function safeName(value, fallback = '') {
const cleaned = String(value || '')
.trim()
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
.replace(/\s+/g, ' ')
.replace(/^-+|-+$/g, '');
return cleaned || fallback;
}
export function getConfig() {
const url = String(process.env.FILEBROWSER_URL || '').trim().replace(/\/+$/, '');
return {
url,
token: process.env.FILEBROWSER_TOKEN || '',
clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients'),
subsRoot: normalizePath(process.env.FILEBROWSER_SUBS_ROOT || '/fourgebranding/team'),
configured: Boolean(url),
};
}
export function getToken(config) {
const token = String(config.token || '').trim();
if (!token) throw new Error('FILEBROWSER_TOKEN not configured');
return token;
}
export async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) {
const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString();
const url = `${config.url}${endpoint}?${qs}`;
const token = getToken(config);
const response = await fetch(url, {
method,
headers: { Authorization: `Bearer ${token}`, ...headers },
body,
});
if (!response.ok) {
const text = await response.text().catch(() => '');
const error = new Error(text || `FileBrowser ${response.status}`);
error.status = response.status;
throw error;
}
const text = await response.text();
try {
return text ? JSON.parse(text) : null;
} catch {
return text;
}
}
function isAlreadyExistsError(error) {
const message = String(error?.message || '').toLowerCase();
return error?.status === 409 || message.includes('exist') || message.includes('conflict');
}
// Creates a single directory whose parent is already known to exist.
// Much cheaper than ensureDirectory, which re-walks the path from the root.
export async function createDirectory(config, fullPath) {
try {
await fbFetch(config, 'POST', '/api/resources', {
params: { path: normalizePath(fullPath), isDir: 'true' },
});
} catch (error) {
if (!isAlreadyExistsError(error)) throw error;
}
}
// Runs tasks with bounded concurrency, preserving input order in the result.
export async function pooled(items, limit, worker) {
const results = new Array(items.length);
let cursor = 0;
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (cursor < items.length) {
const index = cursor;
cursor += 1;
results[index] = await worker(items[index], index);
}
});
await Promise.all(runners);
return results;
}
export async function ensureDirectory(config, fullPath) {
const parts = normalizePath(fullPath).split('/').filter(Boolean);
let current = '/';
for (const part of parts) {
current = joinPath(current, part);
try {
await fbFetch(config, 'POST', '/api/resources', {
params: { path: current, isDir: 'true' },
});
} catch (error) {
if (!isAlreadyExistsError(error)) throw error;
}
}
}
// Returns directory names at `path`, or null when the path itself does not exist.
export async function listDirectories(config, path) {
let payload;
try {
payload = await fbFetch(config, 'GET', '/api/resources', { params: { path: normalizePath(path) } });
} catch (error) {
if (error?.status === 404) return null;
throw error;
}
// This FileBrowser returns child directories under `folders`; older builds use a mixed `items` array.
if (Array.isArray(payload?.folders)) return payload.folders.map(folder => folder.name);
const items = payload?.items || [];
return items.filter(item => item.type === 'directory' || item.isDir).map(item => item.name);
}
export function createAdminClient() {
const supabaseUrl = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !serviceKey) throw new Error('Supabase admin env not configured');
return createClient(supabaseUrl, serviceKey, {
auth: { persistSession: false, autoRefreshToken: false },
});
}
-12
View File
@@ -37,18 +37,6 @@ function safeName(value, fallback = '') {
return cleaned || fallback;
}
function parseBody(body) {
if (!body) return {};
if (typeof body === 'string') {
try {
return JSON.parse(body);
} catch {
return {};
}
}
return body;
}
async function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
+225
View File
@@ -0,0 +1,225 @@
import {
PROJECT_DEFAULT_SUBFOLDERS,
REQUEST_DEFAULT_SUBFOLDERS,
createAdminClient,
createDirectory,
ensureDirectory,
getConfig,
joinPath,
listDirectories,
pooled,
safeName,
} from './_lib/filebrowser.js';
export const config = { maxDuration: 300 };
// Leave headroom under maxDuration so a partial run still returns its report.
const TIME_BUDGET_MS = 235000;
const CONCURRENCY = 8;
function json(res, status, body) {
res.status(status).setHeader('Content-Type', 'application/json');
res.setHeader('Cache-Control', 'no-store');
res.send(JSON.stringify(body));
}
function isAuthorized(req) {
const expected = String(process.env.FOLDER_RECONCILE_SECRET || '').trim();
if (!expected) return false;
const header = req.headers['x-reconcile-secret'];
const provided = String(Array.isArray(header) ? header[0] : header || '').trim();
if (provided.length !== expected.length) return false;
let diff = 0;
for (let i = 0; i < expected.length; i += 1) diff |= expected.charCodeAt(i) ^ provided.charCodeAt(i);
return diff === 0;
}
export default async function handler(req, res) {
if (req.method !== 'POST') return json(res, 405, { error: 'Method not allowed' });
if (!isAuthorized(req)) return json(res, 401, { error: 'Unauthorized' });
const fbConfig = getConfig();
if (!fbConfig.configured) {
return json(res, 200, { ok: false, configured: false, warning: 'FileBrowser is not configured.' });
}
const startedAt = Date.now();
const outOfTime = () => Date.now() - startedAt > TIME_BUDGET_MS;
const created = [];
const orphans = [];
const errors = [];
const record = (type, path) => created.push({ type, path });
const fail = (scope, error) => errors.push({ scope, message: String(error?.message || error).slice(0, 300) });
try {
const admin = createAdminClient();
const [companiesResult, projectsResult, tasksResult, subsResult] = await Promise.all([
admin.from('companies').select('id, name'),
admin.from('projects').select('id, name, company_id'),
admin.from('tasks').select('id, title, project_id'),
admin.from('profiles').select('id, name').eq('role', 'external'),
]);
for (const result of [companiesResult, projectsResult, tasksResult, subsResult]) {
if (result.error) throw result.error;
}
const companies = companiesResult.data || [];
const projects = projectsResult.data || [];
const tasks = tasksResult.data || [];
const subs = subsResult.data || [];
const rootDirs = await listDirectories(fbConfig, fbConfig.clientRoot);
if (rootDirs === null) {
await ensureDirectory(fbConfig, fbConfig.clientRoot);
record('client-root', fbConfig.clientRoot);
}
const rootSet = new Set(rootDirs || []);
let complete = true;
for (const company of companies) {
if (outOfTime()) { complete = false; break; }
const companyName = safeName(company.name, company.id);
const companyPath = joinPath(fbConfig.clientRoot, companyName);
const projectsPath = joinPath(companyPath, 'Projects');
const companyProjects = projects.filter(project => project.company_id === company.id);
try {
if (!rootSet.has(companyName)) {
await ensureDirectory(fbConfig, companyPath);
record('company', companyPath);
}
let projectDirs = await listDirectories(fbConfig, projectsPath);
if (projectDirs === null) {
if (companyProjects.length === 0) continue;
await ensureDirectory(fbConfig, projectsPath);
record('projects-root', projectsPath);
projectDirs = [];
}
const projectDirSet = new Set(projectDirs);
// Phase 1: create project folders that do not exist yet, with their defaults.
const missingProjects = companyProjects.filter(p => !projectDirSet.has(safeName(p.name, p.id)));
await pooled(missingProjects, CONCURRENCY, async (project) => {
const projectPath = joinPath(projectsPath, safeName(project.name, project.id));
try {
await createDirectory(fbConfig, projectPath);
record('project', projectPath);
for (const folderName of PROJECT_DEFAULT_SUBFOLDERS) {
await createDirectory(fbConfig, joinPath(projectPath, folderName));
}
} catch (error) {
fail(projectPath, error);
}
});
// Phase 2: list every project folder once to learn which task folders exist.
const existingProjects = companyProjects.filter(p => projectDirSet.has(safeName(p.name, p.id)));
const listings = await pooled(existingProjects, CONCURRENCY, async (project) => {
const projectPath = joinPath(projectsPath, safeName(project.name, project.id));
try {
return await listDirectories(fbConfig, projectPath);
} catch (error) {
fail(projectPath, error);
return null;
}
});
// Phase 3: create the missing leaves, flattened so the pool stays saturated.
const work = [];
existingProjects.forEach((project, index) => {
const childDirs = listings[index];
if (childDirs === null) return;
const childSet = new Set(childDirs);
const projectPath = joinPath(projectsPath, safeName(project.name, project.id));
for (const folderName of PROJECT_DEFAULT_SUBFOLDERS) {
if (!childSet.has(folderName)) work.push({ type: 'project-subfolder', path: joinPath(projectPath, folderName), children: [] });
}
for (const task of tasks.filter(t => t.project_id === project.id)) {
const taskName = safeName(task.title, task.id);
if (childSet.has(taskName)) continue;
work.push({ type: 'task', path: joinPath(projectPath, taskName), children: REQUEST_DEFAULT_SUBFOLDERS });
}
});
missingProjects.forEach((project) => {
const projectPath = joinPath(projectsPath, safeName(project.name, project.id));
for (const task of tasks.filter(t => t.project_id === project.id)) {
work.push({ type: 'task', path: joinPath(projectPath, safeName(task.title, task.id)), children: REQUEST_DEFAULT_SUBFOLDERS });
}
});
await pooled(work, CONCURRENCY, async (item) => {
if (outOfTime()) { complete = false; return; }
try {
await createDirectory(fbConfig, item.path);
record(item.type, item.path);
for (const folderName of item.children) {
await createDirectory(fbConfig, joinPath(item.path, folderName));
}
} catch (error) {
fail(item.path, error);
}
});
// Reported only — never deleted, since these may hold real work.
const expected = new Set(companyProjects.map(p => safeName(p.name, p.id)));
for (const dirName of projectDirSet) {
if (!expected.has(dirName)) orphans.push(joinPath(projectsPath, dirName));
}
} catch (error) {
fail(companyPath, error);
}
}
if (subs.length > 0 && !outOfTime()) {
try {
const subDirs = await listDirectories(fbConfig, fbConfig.subsRoot);
if (subDirs === null) {
await ensureDirectory(fbConfig, fbConfig.subsRoot);
record('subs-root', fbConfig.subsRoot);
}
const subDirSet = new Set(subDirs || []);
const missingSubs = subs.filter(profile => !subDirSet.has(safeName(profile.name, profile.id)));
await pooled(missingSubs, CONCURRENCY, async (profile) => {
const subPath = joinPath(fbConfig.subsRoot, safeName(profile.name, profile.id));
try {
await createDirectory(fbConfig, subPath);
record('subcontractor', subPath);
} catch (error) {
fail(subPath, error);
}
});
} catch (error) {
fail(fbConfig.subsRoot, error);
}
}
return json(res, 200, {
ok: errors.length === 0,
complete,
elapsedMs: Date.now() - startedAt,
configured: true,
createdCount: created.length,
created,
orphanCount: orphans.length,
orphans,
errorCount: errors.length,
errors: errors.slice(0, 50),
});
} catch (error) {
return json(res, error?.status || 500, {
ok: false,
complete: false,
elapsedMs: Date.now() - startedAt,
error: String(error?.message || 'Folder reconcile failed.'),
createdCount: created.length,
created,
errorCount: errors.length,
errors: errors.slice(0, 50),
});
}
}
-759
View File
@@ -1,759 +0,0 @@
# Fourge Portal Layout System
This is the single source of truth for dashboard/profile visual structure and UI geometry.
## 1) Global Frame
- Viewport app shell: `height: 100vh`, `overflow: hidden`
- Main content gutter: `24px` all sides
- Sidebar: `width: 76px`, `top: 24px`, `left: 24px`, `height: calc(100vh - 48px)`, `border-radius: 8px`
- Main wrapper offset from sidebar: `margin-left: 100px`
- Page rhythm unit: `24px` (header spacing, card gaps, section gaps)
## 2) Theme + Background
- Background ownership is `body` via `background: var(--bg)`.
- Dark base token `--bg` is full gradient:
- radial glow + vertical dark gradient.
- Light base token `--bg` is full gradient:
- gray radial glow + white vertical gradient.
- A faint ambient grid may sit above the base gradient but behind all app content:
- use a fixed `body::before` layer with very low-contrast line color
- keep the static grid a touch more visible than before, but still quiet
- any animation should be tiny spark nodes that travel on the grid lines, not wide screen sweeps
- use several small sparks across the page with varied directions so the motion feels distributed rather than like one falling line
- avoid additional decorative shell/menu entrance animations so the sparks are the only ambient page motion
- keep animation slow and drifting, not pulsing or flashy
- keep the grid masked/faded so it is strongest near the top and softer lower in the page
- Auth/login screens use the same shared `body` background and spark effect as the app shell; auth surfaces should stay transparent around the card so the global background remains visible.
- Do not use `html` theme-gradient scripting for Safari chrome behavior.
## 3) Tokens
- 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)`
- Border light: `rgba(0,0,0,0.1)`
- Text primary dark/light: `#ffffff / #0d0d0d`
- Text secondary dark/light: `#a8a8a8 / rgba(0,0,0,0.6)`
- Text muted dark/light: `#666666 / rgba(0,0,0,0.38)`
## 4) Typography
- Font family: `Fourge`, then `-apple-system`, `BlinkMacSystemFont`, `'Segoe UI'`, `sans-serif`
- Base font size: `14px`
- Header title: `28px`, `500`, `line-height: 1.2`
- Header subtitle: `13px`
- Widget title: `11px`, `500`, uppercase, `letter-spacing: 0.8px`
- Section header (new standard): `18px`, `500`, Title Case, `letter-spacing: 0.2px`, `line-height: 1.1`
- Body table text: `12px/13px` by column importance
## 5) Card System
- Default widget shell:
- `background: var(--card-bg)`
- `border: 1px solid var(--border)`
- `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
- Site header: `padding-top: 24px`, `padding-bottom: 24px`
- Right control row:
- Search icon button: `32x32`
- Search button to theme toggle space: `7px` (`search-wrap margin-right`)
- Theme toggle: `32x32`
- Theme toggle to avatar: `14px` (`avatar-wrap margin-left`)
- Avatar button: `49x49`, circle, `2px` inner ring + `2px` accent outline
## 6.5) Section Control Bars (Tabs + Actions)
- For page-level card controls (ex: Tasks/Projects, Finances tabs):
- container uses: `display: flex`, `align-items: center`, `gap: 4`, `margin-bottom: 10`, `flex-shrink: 0`
- **`margin-bottom: 10` is the site-wide standard gap between any tab/control bar and the card(s) below — use this everywhere, no exceptions**
- tab bar row must always be `min-height: var(--btn-height)` so the gap to the card never shifts when action buttons appear/disappear
- tabs stay on the left in source order
- when a tab has a count, use the same inline badge pattern as task detail tabs:
- `margin-left: 5`
- `font-size: 12`
- `font-weight: 600`
- `background: var(--card-bg-2)`
- `border: 1px solid var(--border)`
- `border-radius: 10px`
- `padding: 3px 8px`
- active tab count uses `var(--accent)` text; inactive uses `var(--text-muted)`
- action buttons group sits on the right using: `margin-left: auto`, `display: flex`, `align-items: center`, `gap: 8`
- do not use hardcoded spacer blocks (`width` filler divs) to force alignment
- icon-only filter/action buttons share the same row and align vertically with add buttons
- filter button wrapper: `<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>``display: flex` prevents block stretching that misaligns the button vertically
- filter button: `className="btn btn-outline"` + `style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}`, icon only (no label), funnel SVG `<path d="M2 4h12M4 8h8M6 12h4" />` at `13×13`; dropdown uses `site-header-avatar-menu` + `site-header-avatar-item` classes
- when the control bar uses a multi-column grid (to align with split-card layouts below), add `align-items: center` to the grid and `min-height: var(--btn-height)` to every column so row height is stable across all tab states
- Tasks/Projects page tab rules:
- default landing tab is `New Requests`
- keep `All Tasks`, `In Progress`, `On Hold`, `In Review`, and `Completed`
- replace the old `To Do` tab with:
- `New Requests` for `not_started` rows on `R00`
- `Revisions` for `not_started` rows on `R01+`
- the project table tabs on the right also use the same count badge pattern
## 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 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`
## 8) Stat Cards
- Card min height: `120px`
- Internal row gap: `21px`
- Label/value/sub spacing:
- Label: `margin-bottom: 5px`
- Value: `30px`, `400`, `letter-spacing: -0.5`, `line-height: 1.1`
- Sub: `12px`, `margin-top: 5px`
- Icon badge: `27x27`, circle
- Icon glyph: `13x13`
## 9) Calendar
- Card uses widget shell
- Header-to-grid gap: `14px`
- Weekday label: `10px`, `600`, `letter-spacing: 0.5`
- Day cell button: `28x28`, circular
- Day number: `12px`
- Today style: bg `#F5A523`, text `#0d0d0d`, `700`
- Dots: up to 3, each `3x3`, gap `2`
- Popover:
- Anchored left of cell: `right: calc(100% + 8px)`, vertical centered
- `width: 210px`, `padding: 10px 12px`, `border-radius: 8px`
- shadow `0 12px 32px rgba(0,0,0,0.45)`
- row dot `6x6`, row text `12px`
## 10) Activity + Performance Rows
- Visible rows target: 5
- Row layout: `display:flex`, `align-items:center`, `gap:10px`
- Row spacing: `margin-top: 10px` from second row onward
- Name text: `13px`
- Meta/date text: `11px`
- Progress track: `height: 4px`, `radius: 2px`
- Percentage width slot: `min-width: 28px`
- Empty states in all cards (dashboard, profile, tasks, projects, etc.):
- any `No ...` message is centered in the card body (`display:flex`, `align-items:center`, `justify-content:center`)
- use shared class treatment (`.card-empty-center`) for consistency
- avoid top-offset-only placement for empty text
## 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`
- sticky behavior for scrollable tables:
- table headers stay fixed while body scrolls
- use shared sticky-head treatment for all app tables (`position: sticky; top: 0`)
- table scrollbars are visually hidden for table scroll containers; wheel/trackpad scrolling remains active
- Body cells:
- primary text: `13px`
- secondary/metrics text: `12px`
- row vertical spacing via cell padding: typically `5px`
- Hot Tasks column widths:
- check `10%`, task `40%`, requested by `35%`, due by `15%`
- Sorting rule:
- Every visible data column header must be sortable.
- Use clickable header controls (`SortTh`) with ascending/descending indicator.
- Exclude only non-data utility/action columns (checkbox-only, icon-only status marker, action buttons).
## 12) Profile Page
- Container: full available content width, column, `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 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`
- 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: 22px`, `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
- 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.25) Shared Portrait Logic
- Shared profile/avatar rendering must use one common resolver path instead of page-specific inline `<img>` fallbacks.
- Canonical shared component: `ProfileAvatar`
- Portrait source resolution order:
- explicit `avatarUrl` prop when already resolved
- `profile.avatar_url`
- profile lookup by `profileId`
- profile lookup by normalized display `name` as a fallback when only sender/actor name exists in derived dashboard data
- initials fallback when no profile image is available
- Shared portrait behavior:
- image uses `object-fit: cover`
- shape is always circular (`border-radius: 50%`)
- fallback initials use accent background with dark text
- layout/header/task/project/dashboard surfaces should reuse the shared portrait resolver instead of hand-rolling image + initials logic
## 12.3) Team Dashboard Data Logic
- Team dashboard is shared across roles, but card data is role-filtered by scoped tasks/projects/companies.
- Focus refresh + realtime refresh should be wired through the shared live-refresh path, not duplicated per page.
- Scoped company/project/task IDs should be resolved through the shared scope resolver for client/external views.
- Tall dashboard list cards should use viewport-driven height with internal scroll instead of hard-coded row counts:
- measure the lower dashboard row against the current window height and size the cards to the remaining viewport space
- do not use a generic fixed tall-card height that can force whole-page scrolling on shorter windows
- do not apply a lower clamp that stops the card from continuing to compress with the window
- scrolling stays inside the card body, not on the full page layout
- `Team Performance` card logic:
- source data is `deliveries` joined to `submissions` and `tasks`
- each delivery row must include `submission.task_id` plus nested task assignee info
- non-team roles must filter deliveries back down to their scoped task IDs before calculating performance rows
- month dropdown options are derived only from months that actually contain scoped delivery data
- initial render must use the first valid month with data; card must not render empty until the dropdown is manually changed
- performer identity resolves first from `task.assigned_to`, then falls back to normalized `sent_by` / display name matching against profiles
- avatar in `Team Performance` follows shared portrait resolution rules above
- counts:
- `version_number === 0` => `new`
- `version_number > 0` => `revision`
- progress bar percentage is performer completed count divided by total completed count in the active month
- Dashboard lower-left task card logic:
- team view remains `Hot Tasks`
- client view remains `Tasks Ready For Review`
- external/subcontractor view is `My Tasks`
- subcontractor `My Tasks` must source directly from scoped tasks assigned to the current subcontractor, not from `is_hot` submission flags
- subcontractor `My Tasks` should include only active assigned tasks:
- `not_started`
- `in_progress`
- `client_review`
- `on_hold`
- subcontractor `My Tasks` should exclude completed/closed states such as:
- `client_approved`
- `invoiced`
- `paid`
- subcontractor `My Tasks` rows should show task, project, status, and due date
## 13) Radius + Geometry Rules
- Dashboard/profile widgets: `8px` radius
- Sidebar: `8px` radius
- Standard app buttons use the `Edit Profile` geometry:
- geometry is tokenized globally in CSS vars and must be reused (no per-page hardcoded button geometry):
- `--btn-height`
- `--btn-padding-x`
- `--btn-radius`
- `--btn-font-size`
- `--btn-font-weight`
- `--btn-letter-spacing`
- `--btn-line-height`
- height: `22px`
- 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
- popup/modal footer action rows are a no-wrap horizontal row:
- `display: flex`
- `justify-content: flex-end`
- `gap: 8px`
- `flex-wrap: nowrap`
- popup/modal action buttons use a stable minimum width so loading labels do not shove adjacent buttons underneath:
- `min-width: 108px`
- center content horizontally
- request/add-task popups, review/revision/reject/amend popups, and other form submit dialogs should keep the save/submit/cancel buttons on one line while the submit label changes to `Saving...` / `Submitting...`
- status action buttons that change the task state must update the page immediately and lock while the save is in flight so users cannot double-press:
- flip local UI state optimistically
- disable related status buttons until request finishes
- if save fails, roll back local state and show error
- route/page loading should continue using the shared `PageLoader` until page data is actually ready
- avoid plain page-level `Loading...` text fallbacks after navigation, or the popup can disappear while the route is still fetching
- pages with longer initial fetches should pass their route loading state into shared layout/page shells so the popup stays up until all first-load data for that screen is ready
- 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
- Sidebar: `200`
- Header dropdowns/tooltips: `300`
- Calendar hover popover: `1002` within card context (`card can be 1001 active`)
- Modal overlay: `1200`
## 14.5) Loading Popup
- Use shared `PageLoader` component for loading overlays instead of page-specific popup implementations.
- Loading popups/overlays, including shared site-wide page loaders, use the same widget shell as dashboard cards:
- `background: var(--popup-bg)`
- `border: 1px solid var(--border)`
- `border-radius: 8px`
- `padding: 18px 21px`
- `backdrop-filter: blur(12px)` + `-webkit-backdrop-filter`
- Popup surface is theme-aware:
- dark mode: deep translucent dark surface
- light mode: bright translucent white surface
- Loading overlay scrim is theme-aware:
- dark mode: `rgba(0,0,0,0.58)`
- light mode: soft light scrim via `--overlay-scrim`
- Loading title uses widget header treatment: `11px`, `500`, uppercase, `letter-spacing: 0.8px`, secondary text
- Progress track:
- height: `4px`
- radius: `2px`
- track color: secondary card tone (`var(--card-bg-2)`)
- progress fill: accent
- Progress/meta text under the bar uses subtext sizing (`12px`) and secondary text
## 14.6) Modal Form Fields
- Modal forms that follow `Edit Profile` styling (including `Add Task`/`New Task`) share the same field typography and theme tokens:
- modal surface transparency matches `Edit Profile` card shell (`background: var(--card-bg)`), not a denser popup-specific override
- field labels: `11px`, `500`, uppercase, `letter-spacing: 0.8px`, color `var(--text-secondary)`
- input/select text: `13px`, color `var(--text-primary)`, left aligned
- textarea text: `13px`, color `var(--text-primary)`
- field surfaces and borders use global theme tokens (`var(--card-bg-2)` + `var(--border)`) for dark/light parity
- modal form action buttons use the standard outline geometry/style (`btn btn-outline`) unless a page explicitly defines a different role
- Finance modal numeric fields (expense amount, invoice money inputs, line-item quantity/rate fields) follow the same shell system instead of custom oversized styles:
- amount/currency field outer shell: `min-height: 42px`, `border-radius: 6px`, `padding: 0 12px`, `background: var(--card-bg-2)`, `border: 1px solid var(--border)`
- currency symbol is separate from the numeric input and uses secondary text
- amount input text: `22px`, `500`, `line-height: 1.1`, right aligned, `font-variant-numeric: tabular-nums`
- line-item numeric inputs keep the normal field feel: `min-height: 32px`, `13px` text
- quantity is centered; rates/prices/totals are right aligned with tabular numerals
- do not use giant red standalone number inputs; red is reserved for negative/danger/read-only emphasis, not default editable money entry
## 14.7) Status Tags
- Shared status tags use `StatusBadge` (`.badge.badge-status`).
- Rule: any workflow/state value (`not_started`, `in_progress`, `on_hold`, `client_review`, `client_approved`, `invoiced`, `paid`, `active`, `completed`, etc.) must render through `StatusBadge` only.
- Do not use raw `span.badge badge-*` for statuses.
- Base badge geometry:
- `display: inline-flex`
- `align-items: center`
- `gap: 4px`
- `padding: 3px 10px`
- `border-radius: 4px`
- `font-size: 11px`
- `font-weight: 400`
- `letter-spacing: 0.3px`
- `white-space: nowrap`
- Status badge geometry override (`.badge-status`):
- `display: inline-flex`
- `align-items: center`
- `justify-content: center`
- `min-width: 78px`
- `height: 20px`
- `padding: 0 8px`
- `font-size: 10px`
- `line-height: 1.05`
- `box-sizing: border-box`
- `padding-top: 1px` (optical vertical-centering correction for current font metrics)
- Status colors are variant classes (`.badge-not_started`, `.badge-in_progress`, etc.) and are theme-aware.
- Non-status chips (example: invoice line-item type `Initial`/`Revision`, or service-type labels) may use `.badge` variants directly.
- When a non-status chip should visually align with status pills, add `.badge-status` to match geometry.
- Exception: compact urgent tag (`.badge-needs_revision`) uses tighter geometry:
- `min-width: 28px`
- `padding: 3px 4px`
- `border-radius: 4px`
- fixed red treatment in both dark/light themes.
## 15) Motion
- Motion vars:
- fast `160ms`
- base `220ms`
- easing `cubic-bezier(0.22, 1, 0.36, 1)`
- Dropdown/menu entrance animation should be minimal; avoid decorative motion when it competes with the shared background spark effect.
## 17) Hover Interaction Contract
- Sidebar, header icon buttons, dropdown items, and avatar menu items must show a visible hover surface before click.
- Single hover source-of-truth block controls these elements.
- 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.
## 17.5) Link Interaction Standard
- Use shared link classes only; do not hand-roll page-specific link hover styles:
- table/text links inside cards and tables: `table-link`
- inline action links/buttons in cards/feeds: `dashboard-inline-link`
- Hover behavior for both classes:
- color changes to accent gold (`var(--accent)`)
- no underline on hover/focus
- cursor remains pointer
- For table-heavy rows where hit targets are tight, row hover may also promote link color to accent; still keep text-only link treatment (no full-row fill just for links).
- Do not rely on inline style color overrides for hover behavior; if a variant is needed, add/extend a shared class in `index.css`.
## 16) Non-Negotiable Implementation Rules
- Keep gradient backgrounds on `body`, not `html`
- Keep widget shell values (`18px 21px`, `8px`, blur 12, border token) consistent
- Maintain global `24px` spacing rhythm for page/frame/grid gaps
- Keep team dashboard card order:
1. Open Tasks
2. Active Projects
3. Net Profit
4. Revenue (wide)
- Keep row 2 order: Recent Activity (left), Tasks In Progress (center), Calendar (right fixed at `280px`)
- Keep the lower dashboard row shared across all three roles:
1. role-specific task list card
2. Team Performance
- Do not add an extra team-only `Client Highlight` card under the shared dashboard rows
## 17) Team Reports
- Team-only tools nav includes `Reports`.
- Reports page is a finance-adjacent audit surface, but separate from the invoice page.
- Primary report source is task-version billing units, not lump tasks:
- `R00` is its own row
- each real revision version (`R01`, `R02`, and so on) is its own row
- Report rows must derive from the same version-unit billing logic as invoice creation:
- initial/new rows map to task-level client invoice units
- revision rows map to submission-level client invoice units
- hidden review-shadow submissions must not create report rows
- Minimum columns:
- company
- project
- task name
- `R##`
- type (`New` / `Revision`)
- version workflow status
- billing status tag (`Not Invoiced`, `Invoiced`, `Paid`)
- Reports page must support filtering by:
- company
- project
- Reports page must support PDF export of the currently filtered rows.
## 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).
- Billing is version-unit based, not task-lump based:
- `R00` is one unit
- `R01` is one unit
- `R02+` are separate units
- Team -> client invoice availability:
- a task/version can appear only if it is client approved
- it must not have been invoiced to the client before
- already invoiced or paid client invoice units must not appear again
- revision availability must be sourced per version submission, not blocked just because the task's `R00` was already invoiced
- invoice add-item UI should show a visible type chip for each unit:
- `New` for `R00`
- `Revision` for `R01+`
- version availability is tied to completed version state, not only the task's current active state:
- if a newer version is now active (`not_started`, `in_progress`, etc.), older completed uninvoiced versions must still appear
- example: if `R02` is now active, uninvoiced `R01` may still appear while `R02` must stay off the invoice list until it is completed/approved
- team invoice popup and standalone team invoice page must both use the same shared version-eligibility source, not separate page-local rules
- clicking a team invoice from the `/finances` invoice list should open the same shared invoice-detail popup content used by the standalone team invoice detail route, not a separate simplified modal
- creating a new team invoice should also open that same shared invoice-detail popup after save
- Subcontractor -> Fourge invoice availability:
- a task/version can appear only if that subcontractor actually worked/delivered that version
- it must be client approved
- it must not have been invoiced by that subcontractor to Fourge before
- already invoiced or paid subcontractor invoice units must not appear again
- Client invoice history and subcontractor invoice history are separate streams:
- the same task/version may exist in client billing and subcontractor billing
- duplicate prevention happens within each stream separately
## 18.0) Request And Task Lifecycle
- A task starts as a request created by `team` or `client`.
- The initial request is version `R00`.
- `R00` request data lives on the initial `submission` row.
- Visible `R##` labels on task lists/cards/detail must use the task's true current version:
- derive from `max(tasks.current_version, submission.version_number)`
- do not derive the visible `R##` from the deadline-bearing submission row
- deadline/request metadata may come from the deadline source submission, but the displayed revision number must still reflect the true current version
- Client and team can amend the current request without creating a new visible revision item:
- amendment stays tied to the same active version
- amendment does not increment `R##`
- Team or subcontractor works the task and places it in review.
- Review submissions belong in the `Submissions` tab and may happen multiple times for the same `R##`.
- Client decision on a review:
- `Approve`:
- task moves to `client_approved`
- task remains on the approved version
- `Reject`:
- task returns to `in_progress`
- same assigned worker stays on the task
- `R##` does not increment by rejection alone
- rejected reason is stored as `Rejected Notes` tied to that same version
- A real revision request creates the next visible version:
- `R00` -> `R01`
- `R01` -> `R02`
- etc.
- when a revision request creates the next `R##`, the task keeps the same last assignee instead of clearing assignment
- status resets to `not_started`, but the assignee remains attached so the work comes back to the same person by default
- Revision requests are separate from rejection notes:
- revision request = new visible request/version item
- rejection note = side note on the current version
- Re-review after rejection must still appear in `Submissions` without creating a fake new visible request/revision row.
## 18.1) Task File Placement Rules
- Company and project folder chain:
- when a new company is created by team, the portal ensures a matching server folder exists:
- `/Clients/{Company}`
- when a company name is changed, the company folder is renamed to match
- when a new subcontractor is created by team, the portal ensures a matching server folder exists:
- `{FILEBROWSER_SUBS_ROOT}/{Subcontractor Name}`
- when a subcontractor name is changed, that subcontractor folder is renamed to match
- when a new project is created, the portal ensures a matching server folder exists:
- `/Clients/{Company}/Projects/{Project}`
- inside each new project folder, the portal also pre-creates:
- `00 Project Files`
- when a project name is changed, the project folder is renamed to match
- when a new task request is created, the portal ensures a matching server folder exists:
- `/Clients/{Company}/Projects/{Project}/{Task}`
- inside each new task folder, the portal also pre-creates:
- `Old Books`
- `Working Files`
- `Survey`
- folder creation now works via FileBrowser API using:
- a valid production `FILEBROWSER_TOKEN`
- FileBrowser source `srv`
- folder sync uses FileBrowser only for server folder creation
- this does not restore the old file-sharing page or task folder tab
- Initial task request files:
- created by `team` or `client`
- attach to the initial request in the `Overview` tab
- stored on the initial `submission`
- file records live in `submission_files`
- request file flow now works in this order:
- ensure the matching task folder exists under `/Clients/{Company}/Projects/{Project}/{Task}`
- upload each attached file into Supabase bucket `submissions`
- store the file row in `submission_files`
- then mirror that same saved file from Supabase into the server-side `Survey` folder
- initial request files are stored in Supabase under the task's `Survey` path for portal access
- storage path format:
- `{taskId}/Survey/{timestamp}_{originalFileName}`
- initial request files are also mirrored into the server-side task folder:
- `/Clients/{Company}/Projects/{Project}/{Task}/Survey`
- the mirror now copies from the saved Supabase storage object on the server side instead of re-uploading the browser file body
- multiple attached request files are processed one by one through that same mirror flow and should all land in the same `Survey` folder
- the live working behavior is:
- request attachments still show inside the portal from Supabase
- the same attachments also appear in the matching server `Survey` folder
- for `Brand Book` requests, sign details are stored directly on the `submission` row
- sign detail payload lives in `submissions.signs`
- old `submission_signs` rows are migrated into `submissions.signs` and no longer used by the portal
- sign info in the `Overview` tab renders as plain text rows with no inset background card
- Revision request files:
- created by `team` or `client`
- attach to the specific revision entry in the `Revisions` tab
- stored on the revision `submission`
- file records live in `submission_files`
- Review submission files:
- created by `team` or `subcontractor`
- attach to the specific submitted review entry in the `Submissions` tab
- stored on the `delivery`
- file records live in `delivery_files`
- Client rejection notes:
- when client rejects a task in review, it must not create a new request/revision item
- rejection opens a note box
- rejection returns the task to `in_progress`
- rejection keeps the same assigned worker on the task
- rejection does not increment the `R##` version by itself
- note is tied to the exact version being rejected
- `R00` rejection note shows beside the `R00` request info
- `R01` rejection note shows beside the `R01` request info
- `R02` rejection note shows beside the `R02` request info
- label should read `Rejected Notes`
- these notes are separate from normal comments and should not inflate the visible comments count
## 18.2) Task Notification Rules
- New request:
- when a new request is created, it emails:
- all team members
- this applies from client request flows and team request flows
- In review:
- when a task is placed in review, it emails:
- the client
- all team members
- the assigned user
- On hold:
- when a task is placed on hold, it emails:
- the client
- all team members
- the assigned user
- Amend request:
- when a request is amended, it emails:
- the client
- all team members
- the assigned user
- Rejected:
- when a client rejects a task, it emails:
- the client
- all team members
- the assigned user
- rejected task email uses the shared task-status layout only
- do not also send a separate legacy revision email
- Approved:
- when a client approves a task, it emails:
- the client
- all team members
- the assigned user
- Revision request:
- when a revision request is created, it emails:
- the client
- all team members
- the assigned user
- revision request should send one shared task-status email only
- do not also send any older legacy revision email
## 19) Finance Flow
- Finance is role-split, not one shared page across all users:
- team finance hub: `/finances`
- client invoice view: `/client-invoices`
- subcontractor invoice view: `/subs-invoices`
- subcontractor PO view: `/my-purchase-orders`
- Finance is shared in concept, but not one universal page component yet:
- team sees the full finance hub
- client sees receive/view/pay invoice flow only
- subcontractor sees invoice-Fourge and PO-review flow only
### 19.1) Team -> Client Invoice Flow
- Team creates invoice for the client.
- Team invoice availability is version-unit based:
- `R00` can bill as new work
- `R01` is tracked but free
- `R02+` can bill as revision units
- A client invoice unit may appear only if:
- it is client approved
- it has not already been invoiced to the client
- Team sends invoice to the client.
- Invoice send email goes to:
- the client recipient
- all team members
- Client does not create invoices back to Fourge in the portal.
- Client only receives, views, pays, and downloads invoice records.
- Client payment can happen:
- through Stripe / portal pay flow
- outside the portal, then team marks the invoice paid manually
- Once paid:
- invoice status is `paid`
- branded paid/receipt email goes to:
- the client recipient
- all team members
- receipt can be generated/downloaded/sent if needed
- paid client invoices must not be deletable by client or team
- Team invoice detail remains the control point for:
- send / resend invoice
- mark paid
- generate invoice PDF
- generate/send receipt
### 19.2) Client Finance Role
- Clients only see invoices relevant to their company access.
- Clients can:
- view invoices
- filter invoices by company when tied to multiple companies
- download invoice PDFs
- Clients cannot:
- create invoices to Fourge
- manage expenses
- manage subcontractor invoices
- manage POs
### 19.3) Subcontractor -> Fourge Invoice Flow
- Subcontractor creates invoice to bill Fourge for completed work.
- Subcontractor billing is version-based, not task-lump based:
- `R00` is one billable unit
- `R01` is one version unit but free
- `R02+` are version units and billable
- Subcontractor invoice create page builds available rows from actual delivered versions tied to that subcontractor's assigned tasks.
- Only versions the subcontractor actually delivered should appear as available invoice rows.
- Subcontractor invoice availability must dedupe per `task + version`.
- If a subcontractor invoice already includes a specific version, that same version must not show up again in later subcontractor invoices.
- A subcontractor task/version is available only when:
- it is client approved
- that subcontractor delivered/worked that version
- it has not already been invoiced to Fourge by that subcontractor
- Version availability follows the same older-completed-version rule as team client invoices:
- if a newer version is now active (`not_started`, `in_progress`, etc.), older completed uninvoiced versions must still appear
- example: if `R02` is now active, uninvoiced `R01` may still appear while `R02` must stay off the subcontractor invoice list until it is completed/approved
- subcontractor invoice create page must use that same shared version-eligibility source as the team invoice flows
- That invoice appears to team finance for review/payment.
- Team marks subcontractor invoices `paid`.
- Subcontractors do not normally mark their own invoices paid.
- Once paid:
- subcontractor sees paid status
- branded paid/receipt email goes to:
- that subcontractor
- all team members
- receipt can be generated/downloaded/sent if needed
- Client invoicing and subcontractor invoicing are separate streams:
- team can invoice the client for a task/version
- subcontractor can invoice Fourge for that same task/version
- Client invoice state must not block subcontractor invoice availability by itself.
- Subcontractor invoice state only blocks the same subcontractor task/version from being billed again.
- Subcontractor finance page `/subs-invoices` stays separate from the team finance hub:
- no chart on this page
- top row uses four stat cards only: completed new tasks, completed revisions, invoiced, paid
- invoice history sits below in one full-width card with no secondary right-side companion card
- the `+ Invoice` action uses the same shared popup shell/button treatment as the team finance invoice modal
- subcontractor invoice modal keeps subcontractor-specific fields and billing rules, but visually follows the shared finance popup pattern
- clicking a subcontractor invoice from the list should open the shared invoice detail in a popup, matching the team-side subcontractor invoice interaction
- after a subcontractor creates an invoice, the portal should open that same popup detail view instead of routing away to a separate detail page
- subcontractor invoice popup layout should match the team-side subcontractor invoice popup layout
- subcontractor invoice popup action row should include:
- `Download Invoice`
- `Submit to Team` when still draft
- `Download Receipt` when paid
- subcontractor invoice popup line-item table should include:
- `Work` (`New` or `Revision`)
- `R#` (`R00`, `R01`, and so on)
- `Description`
- `Type` (task service type such as `Brand Book`, `Sign Family`, and so on)
- `Qty`
- `Unit Price`
- `Amount`
- subcontractor invoice detail uses one shared layout across team and subcontractor roles, with role-specific actions loaded into the same detail shell
- shared subcontractor invoice detail pattern:
- uses the shared site header chevron/back pattern from task detail, not a separate page-local header block
- content starts immediately under that shared header using the same task-detail rhythm, with no separate action bar floating above the first card row
- header title styling follows the global site header spec:
- `28px`
- `500`
- `var(--text-primary)`
- `line-height: 1.2`
- header subtitle styling follows the global site header spec:
- `13px`
- `var(--text-muted)`
- two top summary cards are the first content row, using the same `24px` gap rhythm as task detail
- role-specific invoice actions sit inside the top-right summary card header instead of on a separate row
- summary and section labels use widget-title styling:
- `11px`
- `500`
- uppercase
- `letter-spacing: 0.8px`
- `var(--text-secondary)`
- line items live in one full-width card with sticky table header and total footer
- notes card sits below line items when notes exist
### 19.4) Team -> Subcontractor PO Flow
- Team creates purchase order for subcontractor work.
- PO can optionally be tied to a project and task-backed line items.
- PO email sending is currently disabled.
- Subcontractor can approve the PO from their portal view.
- PO approval is separate from subcontractor invoice payment.
- A PO being approved does not itself mean an invoice has been paid.
### 19.5) Simple Mental Model
- Fourge -> Client = invoice out
- Client -> Fourge = payment in
- Subcontractor -> Fourge = invoice in
- Fourge -> Subcontractor = payment out
- Fourge -> Subcontractor before payment = PO / work authorization
-7
View File
@@ -10,7 +10,6 @@
"dependencies": {
"@supabase/supabase-js": "^2.99.3",
"heic-to": "^1.4.2",
"heic2any": "^0.0.4",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.7",
"jszip": "^3.10.1",
@@ -2026,12 +2025,6 @@
"integrity": "sha512-y69thwxfNcEm2Vk8lbOD/cMabnvMJyOREfJYiCHcXCDqlfcPyJoBhyRc8+iDe1B95LRfpbTOpzxzY1xbRkdwBA==",
"license": "LGPL-3.0"
},
"node_modules/heic2any": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/heic2any/-/heic2any-0.0.4.tgz",
"integrity": "sha512-3lLnZiDELfabVH87htnRolZ2iehX9zwpRyGNz22GKXIu0fznlblf0/ftppXKNqS26dqFSeqfIBhAmAj/uSp0cA==",
"license": "MIT"
},
"node_modules/hermes-estree": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
-1
View File
@@ -12,7 +12,6 @@
"dependencies": {
"@supabase/supabase-js": "^2.99.3",
"heic-to": "^1.4.2",
"heic2any": "^0.0.4",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.7",
"jszip": "^3.10.1",
+7 -1
View File
@@ -1,6 +1,6 @@
import { lazy, Suspense, Component } from 'react';
import { BrowserRouter, Routes, Route, Navigate, useParams } from 'react-router-dom';
import { AuthProvider, useAuth } from './context/AuthContext';
import { AuthProvider } from './context/AuthContext';
import ProtectedRoute from './components/ProtectedRoute';
import PageLoader from './components/PageLoader';
@@ -67,6 +67,11 @@ function RedirectProjectDetail() {
return <Navigate to={`/projects/${id}`} replace />;
}
function RedirectClientDetail() {
const { id } = useParams();
return <Navigate to={`/projects/${id}`} replace />;
}
function RedirectToTask() {
const { id } = useParams();
return <Navigate to={`/tasks/${id}`} replace />;
@@ -100,6 +105,7 @@ export default function App() {
<Route path="/dashboard" element={<ProtectedRoute role={['team', 'external', 'client']}><TeamDashboard /></ProtectedRoute>} />
<Route path="/projects/:id" element={<ProtectedRoute role={['team', 'external', 'client']}><ProjectDetailPage /></ProtectedRoute>} />
<Route path="/clients/:id" element={<RedirectClientDetail />} />
<Route path="/tasks/:id" element={<ProtectedRoute role={['team', 'external', 'client']}><TaskDetail /></ProtectedRoute>} />
<Route path="/company" element={<ProtectedRoute role={['team', 'client']}><CompaniesPage /></ProtectedRoute>} />
<Route path="/company/:id" element={<ProtectedRoute role={['team', 'client']}><CompanyDetail /></ProtectedRoute>} />
+1 -2
View File
@@ -23,7 +23,6 @@ export default function FilterDropdown({ value, onChange, options }) {
useEffect(() => {
if (!open) return;
place();
function onDown(e) {
if (btnRef.current?.contains(e.target) || menuRef.current?.contains(e.target)) return;
setOpen(false);
@@ -48,7 +47,7 @@ export default function FilterDropdown({ value, onChange, options }) {
<button
ref={btnRef}
type="button"
onClick={() => setOpen(o => !o)}
onClick={() => { if (!open) place(); setOpen(o => !o); }}
aria-label="Filter"
title="Filter"
style={{
+1 -13
View File
@@ -1,22 +1,10 @@
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
import PageLoader from './PageLoader';
export const POPUP_FIELD_LABEL = {
fontSize: 11,
fontWeight: 500,
color: 'var(--text-secondary)',
textTransform: 'uppercase',
letterSpacing: 0.8,
display: 'block',
marginBottom: 4,
};
export default function InvoiceDetailPopup({
title,
subtitle,
headerRight,
onClose,
blockClose = false,
metaContent, // JSX fields rendered in flat meta grid (no cards)
metaActions, // optional JSX rendered right-aligned beside meta grid (e.g. Edit Dates)
metaCols = 4, // number of grid columns for meta strip
@@ -25,7 +13,7 @@ export default function InvoiceDetailPopup({
children,
}) {
return (
<div style={popupOverlayStyle} onClick={() => { if (!blockClose) onClose?.(); }}>
<div style={popupOverlayStyle}>
<div
style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }}
onClick={e => e.stopPropagation()}
+26 -5
View File
@@ -42,7 +42,7 @@ function TeamNav({ onNav }) {
const isCompaniesActive = location.pathname === '/company' && !location.search.includes('tab=users');
const isUsersActive = location.pathname === '/company' && location.search.includes('tab=users');
const isTasksActive = location.pathname === '/tasks' || location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/requests/');
const isTasksActive = location.pathname === '/tasks' || location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/clients/') || location.pathname.startsWith('/requests/');
return (
<div className="sidebar-section">
@@ -70,7 +70,7 @@ function TeamNav({ onNav }) {
function ClientNav({ onNav }) {
const location = useLocation();
const isTasksActive = location.pathname === '/tasks' || location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/requests/');
const isTasksActive = location.pathname === '/tasks' || location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/clients/') || location.pathname.startsWith('/requests/');
const links = [
{ to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
{ to: '/tasks', label: 'Tasks', icon: ICONS.requests },
@@ -90,7 +90,7 @@ function ClientNav({ onNav }) {
function ExternalNav({ onNav }) {
const location = useLocation();
const isTasksActive = location.pathname === '/tasks' || location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/requests/');
const isTasksActive = location.pathname === '/tasks' || location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/clients/') || location.pathname.startsWith('/requests/');
const links = [
{ to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
{ to: '/tasks', label: 'Tasks', icon: ICONS.requests },
@@ -143,7 +143,7 @@ export default function Layout({ children, loading = false }) {
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 isTaskDetailRoute = location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/requests/');
const isTaskDetailRoute = location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/clients/') || location.pathname.startsWith('/requests/');
const isInvoiceDetailRoute =
location.pathname.startsWith('/finances/') ||
location.pathname.startsWith('/invoices/') ||
@@ -181,7 +181,7 @@ export default function Layout({ children, loading = false }) {
: isReportsRoute
? 'Track task versions, billing state and exportable summaries.'
: isRequestsRoute && !isTaskDetailRoute
? 'Browse and manage all tasks and projects.'
? 'Browse and manage all tasks and clients.'
: isTaskDetailRoute || isInvoiceDetailRoute ? null : "Here's what's happening today.";
const detailBackTarget = isTaskDetailRoute
? '/tasks'
@@ -265,6 +265,27 @@ export default function Layout({ children, loading = false }) {
: <ClientNav onNav={() => setMenuOpen(false)} />
}
<div className="sidebar-mobile-footer">
<button className="sidebar-link" onClick={() => { navigate('/profile'); setMenuOpen(false); }} title="Profile">
<ProfileAvatar profile={currentUser} name={currentUser?.name} size={26} fontSize={10} />
<span className="nav-label">Profile</span>
</button>
<button className="sidebar-link" onClick={toggleTheme} title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}>
<span className="nav-icon">
{theme === 'dark'
? <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="8" cy="8" r="3"/><line x1="8" y1="1" x2="8" y2="2.5"/><line x1="8" y1="13.5" x2="8" y2="15"/><line x1="1" y1="8" x2="2.5" y2="8"/><line x1="13.5" y1="8" x2="15" y2="8"/><line x1="3" y1="3" x2="4.1" y2="4.1"/><line x1="11.9" y1="11.9" x2="13" y2="13"/><line x1="13" y1="3" x2="11.9" y2="4.1"/><line x1="4.1" y1="11.9" x2="3" y2="13"/></svg>
: <svg viewBox="0 0 16 16" fill="currentColor" stroke="none"><path d="M14 8.53A6 6 0 1 1 7.47 2 4.67 4.67 0 0 0 14 8.53Z"/></svg>}
</span>
<span className="nav-label">{theme === 'dark' ? 'Light mode' : 'Dark mode'}</span>
</button>
<button className="sidebar-link" onClick={handleLogout} title="Sign Out">
<span className="nav-icon">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M6 14H3a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1h3"/><polyline points="10 11 13 8 10 5"/><line x1="13" y1="8" x2="6" y2="8"/></svg>
</span>
<span className="nav-label">Sign Out</span>
</button>
</div>
</aside>
<div className="main-wrapper">
+1 -1
View File
@@ -2,7 +2,7 @@ function normalizeName(value) {
return String(value || '').trim().toLowerCase();
}
export function resolveProfileAvatar({
function resolveProfileAvatar({
avatarUrl = '',
profile = null,
profileId = '',
+48 -39
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect } from 'react';
import { supabase } from '../lib/supabase';
import { serviceTypes } from '../data/mockData';
import FileAttachment from './FileAttachment';
@@ -49,44 +49,23 @@ export default function RequestForm({
requestedBy: showRequester ? (currentUser?.id || '') : '',
...(initialValues || {}),
}));
const prevCompanyIdRef = useRef(initialValues?.companyId || initialCompanyId || null);
const [files, setFiles] = useState([]);
const [existingProjects, setExistingProjects] = useState([]);
const [customProjects, setCustomProjects] = useState([]);
const [isTypingProject, setIsTypingProject] = useState(false);
const [newProjectName, setNewProjectName] = useState('');
const [companyUsers, setCompanyUsers] = useState([]);
const [signFamilies, setSignFamilies] = useState([]);
const [signCount, setSignCount] = useState('');
const [signs, setSigns] = useState([]);
const [localError, setLocalError] = useState('');
const usesSignFields = form.serviceType === 'Brand Book';
useEffect(() => {
supabase.from('sign_families').select('name').order('sort_order').then(({ data }) => setSignFamilies((data || []).map(r => r.name)));
}, []);
// Single-company user: lock selection to their one company (derived, not stored).
const companyId = form.companyId || (companies.length === 1 ? companies[0].id : '') || initialCompanyId;
useEffect(() => {
if (!usesSignFields) { setSignCount(''); setSigns([]); }
}, [usesSignFields]);
useEffect(() => {
const n = Math.max(0, parseInt(signCount) || 0);
setSigns(prev => {
if (n < prev.length) return prev.slice(0, n);
if (n > prev.length) {
const extra = Array.from({ length: n - prev.length }, () => ({ id: crypto.randomUUID(), signName: '', signFamily: '' }));
return [...prev, ...extra];
}
return prev;
});
}, [signCount]);
const companyId = form.companyId || initialCompanyId;
useEffect(() => {
if (!companyId) { setExistingProjects([]); setCompanyUsers([]); return; }
if (!companyId) return;
Promise.all([
supabase.from('projects').select('id, name').eq('company_id', companyId).order('name'),
showRequester
@@ -110,13 +89,6 @@ export default function RequestForm({
setCompanyUsers(merged);
}
});
if (companyId !== prevCompanyIdRef.current) {
setForm(f => ({ ...f, project: '', requestedBy: '' }));
setCustomProjects([]);
setIsTypingProject(false);
setNewProjectName('');
}
prevCompanyIdRef.current = companyId;
}, [companyId]); // eslint-disable-line react-hooks/exhaustive-deps
const set = (field) => (e) => {
@@ -129,9 +101,41 @@ export default function RequestForm({
setSigns(prev => prev.map(s => s.id === id ? { ...s, [field]: value } : s));
};
// Sign rows follow the count field; resized on edit rather than in an effect.
const handleSignCountChange = (e) => {
const raw = e.target.value;
setSignCount(raw);
const n = Math.max(0, parseInt(raw) || 0);
setSigns(prev => {
if (n < prev.length) return prev.slice(0, n);
if (n > prev.length) {
const extra = Array.from({ length: n - prev.length }, () => ({ id: crypto.randomUUID(), signName: '', signFamily: '' }));
return [...prev, ...extra];
}
return prev;
});
};
const handleServiceTypeChange = (e) => {
set('serviceType')(e);
if (e.target.value !== 'Brand Book') { setSignCount(''); setSigns([]); }
};
// Switching company invalidates the project/requester picked under the old one.
const handleCompanyChange = (e) => {
setForm(f => ({ ...f, companyId: e.target.value, project: '', requestedBy: '' }));
setCustomProjects([]);
setIsTypingProject(false);
setNewProjectName('');
};
// No company selected yet means nothing loaded applies, so read through as empty.
const activeProjects = companyId ? existingProjects : [];
const activeCompanyUsers = companyId ? companyUsers : [];
const allProjectNames = [
...existingProjects.map(p => p.name),
...customProjects.filter(name => !existingProjects.some(p => p.name === name)),
...activeProjects.map(p => p.name),
...customProjects.filter(name => !activeProjects.some(p => p.name === name)),
];
const handleProjectSelect = (e) => {
@@ -157,7 +161,7 @@ export default function RequestForm({
const showCompanySelect = companies.length > 1 || showRequester;
const requesterOptions = [
...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)` }] : []),
...companyUsers.filter(u => u.id !== currentUser?.id),
...activeCompanyUsers.filter(u => u.id !== currentUser?.id),
];
const handleSubmit = (e) => {
@@ -223,11 +227,16 @@ export default function RequestForm({
) : showCompanySelect ? (
<div className="form-group">
<label style={modalLabelStyle}>Company *</label>
<select value={form.companyId} onChange={e => setForm(f => ({ ...f, companyId: e.target.value }))} required style={modalInputStyle}>
<select value={companyId} onChange={handleCompanyChange} required style={modalInputStyle}>
<option value="">Select company...</option>
{companies.map(co => <option key={co.id} value={co.id}>{co.name}</option>)}
</select>
</div>
) : companies.length === 1 ? (
<div className="form-group">
<label style={modalLabelStyle}>Company</label>
<input value={companies[0].name} disabled style={{ ...modalInputStyle, opacity: 0.5, cursor: 'not-allowed' }} />
</div>
) : <div />}
<div className="form-group">
<label style={modalLabelStyle}>Project {!lockedFields.includes('project') && '*'}</label>
@@ -253,7 +262,7 @@ export default function RequestForm({
<div className="grid-2">
<div className="form-group">
<label style={modalLabelStyle}>Service Type *</label>
<select value={form.serviceType} onChange={set('serviceType')} required style={modalInputStyle}>
<select value={form.serviceType} onChange={handleServiceTypeChange} required style={modalInputStyle}>
<option value="">Select service...</option>
{serviceTypes.map(s => <option key={s} value={s}>{s}</option>)}
</select>
@@ -285,7 +294,7 @@ export default function RequestForm({
{/* Row 3: Title/Location + Mark as Hot inline */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 12, alignItems: 'end', marginBottom: 16 }}>
<div className="form-group" style={{ marginBottom: 0 }}>
<label style={modalLabelStyle}>Title / Location *</label>
<label style={modalLabelStyle}>Project / Location *</label>
<input type="text" placeholder="e.g. City, State or Site Name" value={form.title} onChange={set('title')} required style={modalInputStyle} />
</div>
<label style={{ ...modalLabelStyle, display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', marginBottom: 4, whiteSpace: 'nowrap' }}>
@@ -303,7 +312,7 @@ export default function RequestForm({
max="50"
placeholder="How many signs?"
value={signCount}
onChange={e => setSignCount(e.target.value)}
onChange={handleSignCountChange}
required
style={{ ...modalInputStyle, width: '100%' }}
/>
+2 -1
View File
@@ -1,7 +1,8 @@
export default function SortTh({ col, children, sortKey, sortDir, onSort, style }) {
export default function SortTh({ col, children, sortKey, sortDir, onSort, style, className }) {
const active = sortKey === col;
return (
<th
className={className}
onClick={() => onSort(col)}
style={{ cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap', ...style }}
>
+27 -36
View File
@@ -5,10 +5,12 @@ import { supabase } from '../lib/supabase';
import { useAuth } from '../context/AuthContext';
import { sendEmail } from '../lib/email';
import { isCompletedVersionEligible } from '../lib/invoiceVersionRules';
import { isReviewShadowDescription } from '../lib/taskVersions';
import { useActionLock } from '../hooks/useActionLock';
const INVOICE_TODAY = new Date().toISOString().split('T')[0];
const REVISION_RATE = 30;
const ELIGIBLE_TASK_STATUSES = new Set(['not_started', 'in_progress', 'on_hold', 'client_review', 'client_approved', 'invoiced', 'paid']);
const FIELD_LABEL_STYLE = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, display: 'block', marginBottom: 4 };
const FIELD_INPUT_STYLE = { minHeight: 42, margin: 0 };
const FINANCE_MODAL_INPUT_STYLE = {
@@ -113,20 +115,20 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o
setLoadingTasks(true);
setError('');
try {
const [{ data: tasks, error: tasksError }, { data: existingInvoices, error: invoicesError }, { data: nextNum, error: nextNumError }] = await Promise.all([
const [{ data: deliveryRows, error: deliveriesError }, { data: existingInvoices, error: invoicesError }, { data: nextNum, error: nextNumError }] = await Promise.all([
// Scoped by who actually delivered each version, not by current task assignment —
// a task can be reassigned mid-flight and the original sub still needs to bill
// for the version(s) they personally delivered.
supabase
.from('tasks')
.select('id, title, status, current_version, project:projects(name)')
.in('status', ['not_started', 'in_progress', 'on_hold', 'client_review', 'client_approved', 'invoiced', 'paid'])
.eq('assigned_to', currentUser.id)
.order('title'),
.from('deliveries')
.select('version_number, sent_by, submission:submissions!inner(task_id, description, task:tasks!inner(id, title, status, current_version, project:projects(name)))'),
supabase
.from('subcontractor_invoices')
.select('id, status, items:subcontractor_invoice_items(task_id, version_number, description)')
.in('status', ['submitted', 'paid']),
supabase.rpc('get_next_sub_invoice_number'),
]);
if (tasksError) throw tasksError;
if (deliveriesError) throw deliveriesError;
if (invoicesError) throw invoicesError;
if (nextNumError) throw nextNumError;
@@ -141,35 +143,19 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o
}).filter(Boolean))
);
const taskIds = (tasks || []).map((task) => task.id).filter(Boolean);
const { data: submissionRows, error: submissionsError } = taskIds.length > 0
? await supabase
.from('submissions')
.select('task_id, deliveries(version_number, sent_by, sent_at)')
.in('task_id', taskIds)
: { data: [], error: null };
if (submissionsError) throw submissionsError;
const deliveriesByTask = new Map();
for (const row of (submissionRows || [])) {
const taskId = row.task_id;
if (!taskId) continue;
const bucket = deliveriesByTask.get(taskId) || new Map();
for (const delivery of asArray(row.deliveries)) {
if ((delivery.sent_by || '').trim().toLowerCase() !== (currentUser.name || '').trim().toLowerCase()) continue;
const myName = (currentUser.name || '').trim().toLowerCase();
const unitsByKey = new Map();
for (const delivery of (deliveryRows || [])) {
if ((delivery.sent_by || '').trim().toLowerCase() !== myName) continue;
const submission = delivery.submission;
const task = submission?.task;
if (!task || !ELIGIBLE_TASK_STATUSES.has(task.status)) continue;
if (isReviewShadowDescription(submission.description)) continue;
const version = Number(delivery.version_number || 0);
if (!bucket.has(version)) bucket.set(version, delivery);
}
deliveriesByTask.set(taskId, bucket);
}
const units = (tasks || []).flatMap((task) => {
const versions = [...(deliveriesByTask.get(task.id)?.keys() || [])].sort((a, b) => a - b);
return versions
.filter((version) => isCompletedVersionEligible(task, version))
.map((version) => {
if (!isCompletedVersionEligible(task, version)) continue;
const key = `${task.id}:${version}`;
return {
if (invoicedUnitKeys.has(key) || unitsByKey.has(key)) continue;
unitsByKey.set(key, {
key,
task_id: task.id,
version_number: version,
@@ -178,9 +164,10 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o
project_name: task.project?.name || '',
description: `${task.project?.name ? `${task.project.name}` : ''}${task.title} ${versionLabel(version)}`,
rate: subcontractorVersionRate(version, rate),
};
});
}).filter((unit) => !invoicedUnitKeys.has(unit.key));
}
const units = [...unitsByKey.values()].sort((a, b) => a.title.localeCompare(b.title) || a.version_number - b.version_number);
setCompletedTasks(units);
} catch (err) {
@@ -360,6 +347,8 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o
</div>
{loadingTasks ? (
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>Loading</div>
) : error ? (
<div style={{ fontSize: 12, color: 'var(--danger)' }}>{error}</div>
) : completedTasks.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>No completed tasks available to invoice.</div>
) : (
@@ -455,6 +444,8 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o
</div>
{loadingTasks ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading...</p>
) : error ? (
<p style={{ fontSize: 13, color: 'var(--danger)' }}>{error}</p>
) : completedTasks.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>No completed tasks available to invoice.</p>
) : (
+25 -1
View File
@@ -30,6 +30,7 @@
--accent-hover: #e09510;
/* Layout2: solid, no transparency. bg 90% K, cards 88% K */
--bg: #1a1a1a;
--page-bg-solid: #1a1a1a;
--card-bg: #1f1f1f;
--card-bg-2: #262626;
/* Single source of truth for all card frames across the site.
@@ -135,6 +136,7 @@
--bg:
radial-gradient(560px circle at 58% -6%, rgba(78,78,78,0.42) 0%, rgba(78,78,78,0.29) 24%, rgba(78,78,78,0.16) 48%, rgba(78,78,78,0.06) 72%, rgba(78,78,78,0) 100%),
linear-gradient(180deg, #ffffff 0%, #ffffff 42%, #ffffff 100%);
--page-bg-solid: #ffffff;
--card-bg: rgba(0,0,0,0.02);
--card-bg-2: rgba(0,0,0,0.08);
--popup-bg: rgba(255,255,255,0.92);
@@ -374,6 +376,8 @@ body::before, body::after { display: none; }
.grid-card:hover { background: var(--card-bg-2); }
[data-theme="light"] .grid-card:hover { background: #fafafa; }
.sidebar-mobile-footer { display: none; }
.sidebar-bottom {
margin-top: auto; padding: 16px 8px 0;
border-top: 1px solid var(--border);
@@ -1082,7 +1086,7 @@ tbody tr:hover .table-link {
position: sticky;
top: 0;
z-index: 3;
background: transparent !important;
background: linear-gradient(var(--card-bg), var(--card-bg)), var(--page-bg-solid) !important;
}
.table-scroll-fade {
position: static;
@@ -1659,6 +1663,13 @@ select option { background: #222; color: #fff; }
}
.sidebar.sidebar-open { left: 0; }
/* Account actions pinned to bottom of mobile sidebar */
.sidebar-mobile-footer {
display: flex; flex-direction: column; gap: 4px;
margin-top: auto; padding: 12px 8px;
border-top: 1px solid var(--border);
}
/* Show overlay when menu open */
.sidebar-overlay { display: block; }
@@ -1808,3 +1819,16 @@ button.section-tab-btn:focus-visible {
@media (max-width: 560px) {
.dash-stat-grid { grid-template-columns: 1fr; }
}
/* Tasks stats row — responsive 6→3→2→1 */
.tasks-stat-grid { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 24px; margin-bottom: 24px; }
@media (max-width: 1200px) { .tasks-stat-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; } }
@media (max-width: 768px) { .tasks-stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; } }
@media (max-width: 480px) { .tasks-stat-grid { grid-template-columns: 1fr; } }
/* Tasks table progressive column hiding — keep Status, Name, Assigned at smallest */
@media (max-width: 1200px) { .tcol-company { display: none; } }
@media (max-width: 1040px) { .tcol-due { display: none; } }
@media (max-width: 920px) { .tcol-rev { display: none; } }
@media (max-width: 820px) { .tcol-type { display: none; } }
@media (max-width: 720px) { .tcol-project { display: none; } }
+1 -8
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
export function useLiveClock() {
function useLiveClock() {
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000);
@@ -20,10 +20,3 @@ export function DashboardBanner() {
</div>
);
}
export function getGreeting() {
const h = new Date().getHours();
if (h < 12) return 'Good morning';
if (h < 17) return 'Good afternoon';
return 'Good evening';
}
+1 -1
View File
@@ -22,6 +22,6 @@ export function getRevisionChargeQuantity(versionNumber, revisionType) {
// Parses version number from a subcontractor invoice item description like
// "Project • Task R01". Legacy fallback only — new rows store version_number directly.
export function parseVersionFromItemDescription(description = '') {
const match = String(description).match(/[\-]\s*R(\d{2})\b/i);
const match = String(description).match(/[-]\s*R(\d{2})\b/i);
return match ? Number(match[1]) : 0;
}
+10
View File
@@ -21,6 +21,16 @@ export const popupSurfaceStyle = {
boxShadow: 'var(--popup-shadow)',
};
export const POPUP_FIELD_LABEL = {
fontSize: 11,
fontWeight: 500,
color: 'var(--text-secondary)',
textTransform: 'uppercase',
letterSpacing: 0.8,
display: 'block',
marginBottom: 4,
};
export const popupMenuStyle = {
background: 'var(--popup-bg)',
border: '1px solid var(--border)',
+16 -13
View File
@@ -159,7 +159,6 @@ export default function BrandBook() {
const projectLogoRef = useRef();
const clientLogoRef = useRef();
const [filterCompany, setFilterCompany] = useState('');
const [selectedBookIds, setSelectedBookIds] = useState([]);
const { sortKey: bbSortKey, sortDir: bbSortDir, toggle: bbToggle, sort: bbSort } = useSortable('updated_at');
useEffect(() => {
@@ -175,7 +174,8 @@ export default function BrandBook() {
const timeoutId = setTimeout(() => {
if (address.includes(',') && address.length >= 12) {
loadMapsFromAddress(address, { silent: true });
// Called through a ref so the debounce isn't reset by the handler's identity.
loadMapsFromAddressRef.current(address, { silent: true });
}
}, 900);
@@ -222,7 +222,6 @@ export default function BrandBook() {
setLoadingBooks(true);
const { data } = await supabase.from('brand_books').select('*').order('updated_at', { ascending: false });
setSavedBooks(data || []);
setSelectedBookIds(prev => prev.filter(id => (data || []).some(book => book.id === id)));
setLoadingBooks(false);
};
@@ -235,6 +234,8 @@ export default function BrandBook() {
setBookInfo(b => ({ ...b, revision: normalizeRevision(b.revision) }));
};
const loadMapsFromAddressRef = useRef(null);
const loadMapsFromAddress = async (address = bookInfo.customerAddress, { silent = false, feature = null } = {}) => {
const trimmed = String(address || '').trim();
if (!trimmed) {
@@ -290,6 +291,10 @@ export default function BrandBook() {
}
};
useEffect(() => {
loadMapsFromAddressRef.current = loadMapsFromAddress;
});
const handleCustomerAddressChange = (e) => {
const value = e.target.value;
setBookInfo(b => ({ ...b, customerAddress: value, siteAddress: value }));
@@ -452,7 +457,7 @@ export default function BrandBook() {
const handleSave = async () => {
if (!bookInfo.clientName.trim()) {
setNotification({ type: 'error', msg: 'Please select a client.' });
setNotification({ type: 'error', msg: 'Please select a company.' });
return;
}
setSaving(true);
@@ -614,7 +619,7 @@ export default function BrandBook() {
const handleGenerate = async () => {
if (!bookInfo.clientName.trim()) {
setNotification({ type: 'error', msg: 'Please select a client.' });
setNotification({ type: 'error', msg: 'Please select a company.' });
return;
}
setGenerating(true);
@@ -682,7 +687,7 @@ export default function BrandBook() {
const handleClientLogoUpload = async (e) => {
const file = e.target.files[0];
if (!file) return;
if (!bookInfo.clientId) { setNotification({ type: 'error', msg: 'Select a client first.' }); return; }
if (!bookInfo.clientId) { setNotification({ type: 'error', msg: 'Select a company first.' }); return; }
setUploadingClientLogo(true);
const ext = file.name.split('.').pop().toLowerCase();
const path = `${bookInfo.clientId}/logo.${ext}`;
@@ -801,13 +806,13 @@ export default function BrandBook() {
</div>
) : (
<div className="table-wrapper">
<table>
<table className="table-sticky-head">
<thead>
<tr>
<SortTh col="project_name" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Name</SortTh>
<SortTh col="revision" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Revision</SortTh>
<SortTh col="sign_count" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Sign Count</SortTh>
<SortTh col="client" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Client</SortTh>
<SortTh col="client" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Company</SortTh>
<SortTh col="updated_at" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Updated</SortTh>
<th></th>
</tr>
@@ -893,7 +898,7 @@ export default function BrandBook() {
<div className="card-title">Brand Book Info</div>
<div className="grid-2">
<div className="form-group">
<label>Client *</label>
<label>Company *</label>
<select value={bookInfo.clientId} onChange={handleClientChange}>
<option value=""> Select client </option>
{clients.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
@@ -1048,7 +1053,7 @@ export default function BrandBook() {
{/* Client info (saved per company) */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<div>
<div style={{ fontSize: 13, fontWeight: 400, color: 'var(--text-primary)' }}>Client Info</div>
<div style={{ fontSize: 13, fontWeight: 400, color: 'var(--text-primary)' }}>Company Info</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>Logo and contact saved to company reused across all brand books.</div>
</div>
<button className="btn btn-outline btn-sm" onClick={handleSaveClientInfo} disabled={savingClientInfo || !bookInfo.clientId}>
@@ -1057,7 +1062,7 @@ export default function BrandBook() {
</div>
<div className="form-group">
<label>Client Logo <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(3.5"×1.5" area, bottom right)</span></label>
<label>Company Logo <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(3.5"×1.5" area, bottom right)</span></label>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{bookInfo.clientLogoUrl && (
<img src={bookInfo.clientLogoUrl} alt="Client logo" style={{ maxHeight: 44, maxWidth: 130, objectFit: 'contain', border: '1px solid var(--border)', borderRadius: 4, padding: 4, background: '#fff' }} />
@@ -2353,7 +2358,6 @@ function DimensionEditorModal({ sourceImage, onApply, onCancel }) {
return (
<div
style={{ ...popupOverlayStyle, zIndex: 9999, padding: 20 }}
onClick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
>
<div style={{ ...popupSurfaceStyle, borderRadius: 8, display: 'flex', flexDirection: 'column', maxWidth: '98vw', maxHeight: '96vh', overflow: 'hidden', padding: 0 }}>
<div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
@@ -3436,7 +3440,6 @@ function PhotoEditorModal({
return (
<div
style={{ ...popupOverlayStyle, zIndex: 9999, padding: 20 }}
onClick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
>
<div style={{ ...popupSurfaceStyle, borderRadius: 8, display: 'flex', flexDirection: 'column', maxWidth: '98vw', maxHeight: '96vh', overflow: 'hidden', padding: 0 }}>
{/* Header */}
+11 -11
View File
@@ -32,7 +32,6 @@ function TeamCompanies() {
const [editingUserId, setEditingUserId] = useState(null);
const [editUserVal, setEditUserVal] = useState('');
const [deletingUserId, setDeletingUserId] = useState(null);
const [filterCompany, setFilterCompany] = useState('');
const [userSubTab, setUserSubTab] = useState(profileRole === 'external' ? 'external' : 'client');
const { sortKey: coSortKey, sortDir: coSortDir, toggle: coToggle, sort: coSort } = useSortable('name');
const { sortKey: clSortKey, sortDir: clSortDir, toggle: clToggle, sort: clSort } = useSortable('name');
@@ -51,6 +50,9 @@ function TeamCompanies() {
setLoading(false);
}
// load() only setStates after awaiting its queries; the rule can't see past the
// call boundary, and load() is shared with the create/delete handlers below.
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { load(); }, []);
const handleCreate = async (e) => {
@@ -76,7 +78,7 @@ function TeamCompanies() {
};
const handleDeleteCompany = async (company) => {
if (!window.confirm(`Delete "${company.name}"? This will permanently delete all projects, jobs, files, and data for this company. This cannot be undone.`)) return;
if (!window.confirm(`Delete "${company.name}"? This will permanently delete all clients, jobs, files, and data for this company. This cannot be undone.`)) return;
await deleteCompanyData(company.id);
setCompanies(prev => prev.filter(c => c.id !== company.id));
load();
@@ -144,8 +146,6 @@ function TeamCompanies() {
console.error('Subcontractor folder sync failed:', folderError);
}
}
if (userForm.role === 'team' || userForm.role === 'external') {
}
setShowNewUser(false);
setUserForm({ name: '', email: '', password: '', company_id: '', role: 'client' });
load();
@@ -191,7 +191,7 @@ function TeamCompanies() {
</div>
{tab === 'companies' && (
<button className="btn btn-primary btn-sm" onClick={() => { setShowNew(v => !v); setShowNewUser(false); }}>
{showNew ? 'Cancel' : '+ New Client'}
{showNew ? 'Cancel' : '+ New Company'}
</button>
)}
{tab === 'users' && (
@@ -230,7 +230,7 @@ function TeamCompanies() {
</div>
<div className="action-buttons">
<button type="submit" className="btn btn-primary" disabled={saving || !newForm.name.trim()}>
{saving ? 'Creating...' : 'Create Client'}
{saving ? 'Creating...' : 'Create Company'}
</button>
<button type="button" className="btn btn-outline" onClick={() => setShowNew(false)}>Cancel</button>
</div>
@@ -239,10 +239,10 @@ function TeamCompanies() {
)}
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{companies.length === 0 ? (
<div className="card-empty-center">No clients</div>
<div className="card-empty-center">No companies</div>
) : (
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table>
<table className="table-sticky-head">
<thead>
<tr>
<SortTh col="name" sortKey={coSortKey} sortDir={coSortDir} onSort={coToggle}>Company</SortTh>
@@ -396,7 +396,7 @@ function TeamCompanies() {
<div className="card-empty-center">No users</div>
) : (
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table>
<table className="table-sticky-head">
<thead>
<tr>
<SortTh col="name" sortKey={clSortKey} sortDir={clSortDir} onSort={clToggle}>Name</SortTh>
@@ -450,7 +450,7 @@ function TeamCompanies() {
<div className="card-empty-center">No subcontractors</div>
) : (
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table>
<table className="table-sticky-head">
<thead>
<tr>
<SortTh col="name" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle}>Name</SortTh>
@@ -517,7 +517,7 @@ function ClientCompanyList() {
<div className="card-empty-center">No companies</div>
) : (
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table>
<table className="table-sticky-head">
<thead>
<tr>
<SortTh col="name" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Company</SortTh>
+1 -2
View File
@@ -6,7 +6,7 @@ import StatusBadge from '../components/StatusBadge';
import { supabase } from '../lib/supabase';
import { useAuth } from '../context/AuthContext';
import { serviceTypes } from '../data/mockData';
import { cleanupTaskStorage, deleteCompanyData } from '../lib/deleteHelpers';
import { deleteCompanyData } from '../lib/deleteHelpers';
import { logActivity } from '../lib/activityLog';
import { ensureProjectFolder, renameCompanyFolder } from '../lib/folderSync';
@@ -70,7 +70,6 @@ export default function CompanyDetail() {
}
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
load();
}, [id]); // eslint-disable-line react-hooks/exhaustive-deps
+3 -4
View File
@@ -116,7 +116,7 @@ export default function ProfilePage() {
setViewedProfile(profile);
setViewedCompanies([...names]);
setPrimaryCompanyAddress(primaryAddress);
} catch (err) {
} catch {
if (!cancelled) setProfileError('Unable to load profile.');
} finally {
if (!cancelled) setLoadingProfile(false);
@@ -443,7 +443,7 @@ export default function ProfilePage() {
{rows.length === 0 ? (
<div style={{ minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No tasks in progress</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '65%' }} />
<col style={{ width: '35%' }} />
@@ -616,7 +616,7 @@ export default function ProfilePage() {
<div style={{ ...dashCardStyle, display: 'flex', flexDirection: 'column', minHeight: 120 }}>
<div style={{ display: 'flex', alignItems: 'stretch', gap: 21 }}>
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>Active Projects</div>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>Active Clients</div>
<div style={{ flex: 1, display: 'flex', alignItems: 'center' }}>
<div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{profileStats.activeProjects}</div>
</div>
@@ -642,7 +642,6 @@ export default function ProfilePage() {
{isSelfView && editOpen && (
<div
style={popupOverlayStyle}
onClick={() => { if (!savingProfile) setEditOpen(false); }}
>
<div
style={{
+94 -68
View File
@@ -1,10 +1,11 @@
import { useState, useEffect } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { useParams, Link } from 'react-router-dom';
import Layout from '../components/Layout';
import PageLoader from '../components/PageLoader';
import ProfileAvatar from '../components/ProfileAvatar';
import StatusBadge from '../components/StatusBadge';
import SortTh from '../components/SortTh';
import FilterDropdown from '../components/FilterDropdown';
import { supabase } from '../lib/supabase';
import { useAuth } from '../context/AuthContext';
import { logActivity } from '../lib/activityLog';
@@ -32,11 +33,9 @@ const MODAL_IN = { fontSize: 13, color: 'var(--text-primary)', background: 'va
export default function ProjectDetailPage() {
const { id } = useParams();
const navigate = useNavigate();
const { currentUser } = useAuth();
const isClient = currentUser?.role === 'client';
const isExternal = currentUser?.role === 'external';
const isTeam = currentUser?.role === 'team';
const { refreshKey } = useLiveRefresh(['tasks', 'projects', 'project_members', 'profiles']);
@@ -49,7 +48,11 @@ export default function ProjectDetailPage() {
const [loading, setLoading] = useState(true);
const [submissions, setSubmissions] = useState([]);
const [deliveries, setDeliveries] = useState([]);
const [activeTab, setActiveTab] = useState('all');
const [activeTab, setActiveTab] = useState('tasks');
const [filterStatus, setFilterStatus] = useState('all');
const [filterRevision, setFilterRevision] = useState('all');
const [filterType, setFilterType] = useState('all');
const [filterAssigned, setFilterAssigned] = useState('all');
const [editingName, setEditingName] = useState(false);
const [nameVal, setNameVal] = useState('');
@@ -143,23 +146,6 @@ export default function ProjectDetailPage() {
setSavingName(false);
};
const handleDeleteProject = async () => {
if (!window.confirm(`Delete project "${project.name}"?`)) return;
const { data: { session } } = await supabase.auth.getSession();
const res = await fetch(`/api/delete-project?id=${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${session?.access_token}` } });
if (!res.ok) { const d = await res.json(); alert(d.error || 'Delete failed'); return; }
navigate(isClient ? '/tasks' : `/company/${company?.id}`);
};
const handleDeleteTask = async (taskId, e) => {
e.stopPropagation();
if (!window.confirm('Delete this task?')) return;
const { data: { session } } = await supabase.auth.getSession();
const res = await fetch(`/api/delete-task?id=${taskId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${session?.access_token}` } });
if (!res.ok) { const d = await res.json(); alert(d.error || 'Delete failed'); return; }
setTasks(prev => prev.filter(t => t.id !== taskId));
};
const handleAddMember = async () => {
setSavingMembers(true);
const currentIds = new Set(members.filter(m => m.profile?.role === 'external').map(m => m.profile_id));
@@ -254,21 +240,51 @@ export default function ProjectDetailPage() {
const doneStatuses = new Set(['client_approved', 'invoiced', 'paid']);
const taskTabs = [
{ id: 'all', label: 'All Tasks' },
{ id: 'new_requests', label: 'New Requests' },
{ id: 'revisions', label: 'Revisions' },
{ id: 'in_progress', label: 'In Progress' },
{ id: 'on_hold', label: 'On Hold' },
{ id: 'client_review', label: 'In Review' },
{ id: 'tasks', label: 'Tasks' },
{ id: 'completed', label: 'Completed' },
...(isTeam ? [{ id: 'members', label: 'Subcontractors' }] : []),
];
const visibleRows = activeTab === 'all' ? sortedRows
: activeTab === 'completed' ? sortedRows.filter(r => doneStatuses.has(r.status))
: activeTab === 'new_requests' ? sortedRows.filter(r => r.status === 'not_started' && Number(r.version || 0) === 0)
: activeTab === 'revisions' ? sortedRows.filter(r => r.status === 'not_started' && Number(r.version || 0) >= 1)
: activeTab === 'members' ? []
: sortedRows.filter(r => r.status === activeTab);
const tabBaseRows = activeTab === 'completed'
? sortedRows.filter(r => doneStatuses.has(r.status))
: sortedRows.filter(r => !doneStatuses.has(r.status));
let visibleRows = tabBaseRows;
if (filterStatus !== 'all') visibleRows = visibleRows.filter(r => r.status === filterStatus);
if (filterRevision === 'new') visibleRows = visibleRows.filter(r => Number(r.version || 0) === 0);
else if (filterRevision === 'revision') visibleRows = visibleRows.filter(r => Number(r.version || 0) >= 1);
if (filterType !== 'all') visibleRows = visibleRows.filter(r => (r.serviceType || '') === filterType);
if (filterAssigned === 'unassigned') visibleRows = visibleRows.filter(r => !r.assignedTo);
else if (filterAssigned !== 'all') visibleRows = visibleRows.filter(r => r.assignedTo === filterAssigned);
const STATUS_FILTER_OPTIONS = activeTab === 'completed'
? [
{ value: 'all', label: 'All Statuses' },
{ value: 'client_approved', label: 'Approved' },
{ value: 'invoiced', label: 'Invoiced' },
{ value: 'paid', label: 'Paid' },
]
: [
{ value: 'all', label: 'All Statuses' },
{ value: 'not_started', label: 'Not Started' },
{ value: 'in_progress', label: 'In Progress' },
{ value: 'on_hold', label: 'On Hold' },
{ value: 'client_review', label: 'In Review' },
];
const REVISION_FILTER_OPTIONS = [
{ value: 'all', label: 'All' },
{ value: 'new', label: 'New (R00)' },
{ value: 'revision', label: 'Revisions (R1+)' },
];
const TYPE_FILTER_OPTIONS = [
{ value: 'all', label: 'All Types' },
...[...new Set(tabBaseRows.map(r => r.serviceType).filter(Boolean))].sort((a, b) => a.localeCompare(b)).map(t => ({ value: t, label: t })),
];
const ASSIGNED_FILTER_OPTIONS = [
{ value: 'all', label: 'All Assignees' },
{ value: 'unassigned', label: 'Unassigned' },
...[...new Map(tabBaseRows.filter(r => r.assignedTo).map(r => [r.assignedTo, r.assignedName || '—'])).entries()]
.sort((a, b) => a[1].localeCompare(b[1]))
.map(([id, name]) => ({ value: id, label: name })),
];
const fmtDate = (d) => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—';
@@ -345,13 +361,8 @@ export default function ProjectDetailPage() {
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
{(() => {
const tabCounts = {
all: rows.length,
new_requests: rows.filter(r => r.status === 'not_started' && Number(r.version || 0) === 0).length,
revisions: rows.filter(r => r.status === 'not_started' && Number(r.version || 0) >= 1).length,
in_progress: rows.filter(r => r.status === 'in_progress').length,
on_hold: rows.filter(r => r.status === 'on_hold').length,
client_review: rows.filter(r => r.status === 'client_review').length,
completed: rows.filter(r => ['client_approved','invoiced','paid'].includes(r.status)).length,
tasks: rows.filter(r => !doneStatuses.has(r.status)).length,
completed: rows.filter(r => doneStatuses.has(r.status)).length,
members: members.filter(m => m.profile?.role === 'external').length,
};
return (
@@ -360,7 +371,7 @@ export default function ProjectDetailPage() {
const count = tabCounts[tab.id] ?? 0;
const active = activeTab === tab.id;
return (
<button key={tab.id} onClick={() => setActiveTab(tab.id)} className={`section-tab-btn${active ? ' is-active' : ''}`}>
<button key={tab.id} onClick={() => { setActiveTab(tab.id); setFilterStatus('all'); setFilterRevision('all'); setFilterType('all'); setFilterAssigned('all'); }} className={`section-tab-btn${active ? ' is-active' : ''}`}>
{tab.label}
{tab.id !== 'all' && count > 0 && <span style={{ marginLeft: 5, fontSize: 12, fontWeight: 600, color: active ? 'var(--accent)' : 'var(--text-muted)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 10, padding: '3px 8px' }}>{count}</span>}
</button>
@@ -373,37 +384,56 @@ export default function ProjectDetailPage() {
);
})()}
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, overflowY: 'auto' }}>
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{activeTab !== 'members' && (
visibleRows.length === 0
? <div className="card-empty-center">No tasks</div>
: <div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden" style={{ overflowY: 'auto' }}>
<div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table className="table-sticky-head" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
<colgroup>
<col style={{ width: '5%' }} />
<col style={{ width: '28%' }} />
<col style={{ width: '13%' }} />
<col style={{ width: '8%' }} />
<col style={{ width: '14%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '30%' }} />
<col style={{ width: '12%' }} />
<col style={{ width: '18%' }} />
<col style={{ width: '16%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '15%' }} />
</colgroup>
<thead>
<tr>
<SortTh col="revision" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TASK_TABLE_TH_STYLE}>R#</SortTh>
<SortTh col="title" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TASK_TABLE_TH_STYLE}>Name</SortTh>
<SortTh col="assigned" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Assigned</SortTh>
<SortTh col="priority" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Priority</SortTh>
<SortTh col="serviceType" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Task Type</SortTh>
<SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Deadline</SortTh>
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Status</SortTh>
<th style={{ ...TASK_TABLE_TH_STYLE, whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span onClick={() => toggle('status')} style={{ cursor: 'pointer', userSelect: 'none' }}>Status<span style={{ marginLeft: 4, opacity: sortKey === 'status' ? 0.85 : 0.2, fontSize: 9 }}>{sortKey === 'status' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span></span>
<FilterDropdown value={filterStatus} onChange={setFilterStatus} options={STATUS_FILTER_OPTIONS} />
</span>
</th>
<th style={{ ...TASK_TABLE_TH_STYLE, whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span onClick={() => toggle('revision')} style={{ cursor: 'pointer', userSelect: 'none' }}>R#<span style={{ marginLeft: 4, opacity: sortKey === 'revision' ? 0.85 : 0.2, fontSize: 9 }}>{sortKey === 'revision' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span></span>
<FilterDropdown value={filterRevision} onChange={setFilterRevision} options={REVISION_FILTER_OPTIONS} />
</span>
</th>
<SortTh col="title" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TASK_TABLE_TH_STYLE}>Project</SortTh>
<th style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center', whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span onClick={() => toggle('assigned')} style={{ cursor: 'pointer', userSelect: 'none' }}>Assigned<span style={{ marginLeft: 4, opacity: sortKey === 'assigned' ? 0.85 : 0.2, fontSize: 9 }}>{sortKey === 'assigned' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span></span>
<FilterDropdown value={filterAssigned} onChange={setFilterAssigned} options={ASSIGNED_FILTER_OPTIONS} />
</span>
</th>
<th style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center', whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span onClick={() => toggle('serviceType')} style={{ cursor: 'pointer', userSelect: 'none' }}>Task Type<span style={{ marginLeft: 4, opacity: sortKey === 'serviceType' ? 0.85 : 0.2, fontSize: 9 }}>{sortKey === 'serviceType' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span></span>
<FilterDropdown value={filterType} onChange={setFilterType} options={TYPE_FILTER_OPTIONS} />
</span>
</th>
<SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Due</SortTh>
</tr>
</thead>
<tbody>
{visibleRows.map(row => {
{visibleRows.length === 0 ? (
<tr><td colSpan={6} style={{ textAlign: 'center', padding: '40px 0', color: 'var(--text-muted)', fontSize: 13 }}>No tasks</td></tr>
) : visibleRows.map(row => {
const avatarStyle = { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, borderRadius: '50%', overflow: 'hidden', verticalAlign: 'middle', flexShrink: 0 };
return (
<tr key={row.rowKey}>
<td style={{ ...TASK_TABLE_TD_BASE }}><StatusBadge status={row.status} /></td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>
<Link to={`/tasks/${row.id}`} className="table-link">{`R${String(row.version).padStart(2, '0')}`}</Link>
</td>
@@ -418,12 +448,8 @@ export default function ProjectDetailPage() {
: <div title="Unassigned" style={{ ...avatarStyle, background: 'rgba(255,255,255,0.08)' }}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.3)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg></div>
}
</td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 11, textAlign: 'center', color: row.isHot ? 'var(--danger)' : 'var(--text-primary)', textTransform: 'uppercase', letterSpacing: 0.5 }}>
{row.isHot ? 'HOT' : 'NO'}
</td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>{row.serviceType}</td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>{fmtShortDate(row.deadline, 'Not specified')}</td>
<td style={{ ...TASK_TABLE_TD_BASE, textAlign: 'center' }}><StatusBadge status={row.status} /></td>
</tr>
);
})}
@@ -433,7 +459,7 @@ export default function ProjectDetailPage() {
)}
{activeTab === 'members' && isTeam && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 12 }}>
{(() => {
const subs = members.filter(m => m.profile?.role === 'external');
if (subs.length === 0) return <div className="card-empty-center">No subcontractors</div>;
@@ -448,7 +474,7 @@ export default function ProjectDetailPage() {
return subSortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
});
return (
<table style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
<table className="table-sticky-head" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
<colgroup>
<col style={{ width: '5%' }} />
<col style={{ width: '30%' }} />
@@ -551,7 +577,7 @@ export default function ProjectDetailPage() {
</div>
{showClientTask && isClient && (
<div style={popupOverlayStyle} onClick={() => { setShowClientTask(false); setClientTaskKey(k => k + 1); setClientTaskError(''); }}>
<div style={popupOverlayStyle}>
<div style={TASK_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}>
<div style={{ ...TASK_MODAL_TITLE_STYLE, marginBottom: 14 }}>New Task</div>
<RequestForm
@@ -573,7 +599,7 @@ export default function ProjectDetailPage() {
)}
{addingMember && isTeam && (
<div style={popupOverlayStyle} onClick={() => { setAddingMember(false); setSelectedExts(new Set()); }}>
<div style={popupOverlayStyle}>
<div style={{ ...TASK_MODAL_SHELL_STYLE, width: 'min(480px, 96vw)', display: 'flex', flexDirection: 'column', gap: 16, padding: 0 }} onClick={e => e.stopPropagation()}>
<div style={{ padding: '18px 21px 0' }}>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 2 }}>Manage Subcontractors</div>
@@ -582,7 +608,7 @@ export default function ProjectDetailPage() {
<div style={{ flex: 1, overflowY: 'auto', padding: '0 21px' }}>
{externalProfiles.length === 0
? <div className="card-empty-center">No subcontractors</div>
: <table style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
: <table className="table-sticky-head" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
<colgroup>
<col style={{ width: '5%' }} />
<col style={{ width: '36%' }} />
+7 -8
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import Layout from '../components/Layout';
import PageLoader from '../components/PageLoader';
@@ -13,7 +13,7 @@ import { useLiveRefresh } from '../hooks/useLiveRefresh';
import { useActionLock } from '../hooks/useActionLock';
import FileAttachment from '../components/FileAttachment';
import { sendTaskStatusUpdate } from '../lib/taskNotifications';
import { encodeRejectedNote, parseRejectedNote, isReviewShadowSubmission, REVIEW_SHADOW_PREFIX, getVisibleTaskSubmissions, getTaskDerivedState } from '../lib/taskVersions';
import { encodeRejectedNote, parseRejectedNote, REVIEW_SHADOW_PREFIX, getVisibleTaskSubmissions, getTaskDerivedState } from '../lib/taskVersions';
import { getCurrentVersionForTask } from '../lib/taskDeadlines';
import { getSubmissionDisplayName, mergeSubmissionDisplayNames } from '../lib/submissionDisplay';
@@ -261,7 +261,6 @@ export default function TaskDetail() {
const [revisionForm, setRevisionForm] = useState({ description: '', deadline: '' });
const [revisionFiles, setRevisionFiles] = useState([]);
const [revisionSaving, setRevisionSaving] = useState(false);
const revisionFileRef = useRef(null);
const [rejectModal, setRejectModal] = useState(false);
const [rejectNote, setRejectNote] = useState('');
const [rejectSaving, setRejectSaving] = useState(false);
@@ -976,7 +975,7 @@ export default function TaskDetail() {
</div>
{comments.length === 0
? <div className="card-empty-center">No comments</div>
: commentRows.map((c, i) => {
: commentRows.map((c) => {
const d = new Date(c.created_at);
const dateStr = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
const timeStr = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
@@ -1035,7 +1034,7 @@ export default function TaskDetail() {
</div>
{reviewModal && (
<div style={popupOverlayStyle} onClick={() => { if (!reviewSaving) { setReviewModal(false); setReviewFiles([]); setReviewNotes(''); } }}>
<div style={popupOverlayStyle}>
<div style={{ ...popupSurfaceStyle, width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 14 }} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }}>Place in Review</div>
<div>
@@ -1054,7 +1053,7 @@ export default function TaskDetail() {
)}
{revisionModal && (
<div style={{ position: 'fixed', inset: 0, background: 'var(--overlay-scrim, rgba(0,0,0,0.58))', zIndex: 1200, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }} onClick={() => { if (!revisionSaving) { setRevisionModal(false); setRevisionForm({ description: '', deadline: '' }); setRevisionFiles([]); } }}>
<div style={{ position: 'fixed', inset: 0, background: 'var(--overlay-scrim, rgba(0,0,0,0.58))', zIndex: 1200, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}>
<div className="card" style={{ ...CARD, width: '100%', maxWidth: 480, display: 'flex', flexDirection: 'column', gap: 18 }} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 18, fontWeight: 500, color: 'var(--text-primary)', letterSpacing: 0.2, lineHeight: 1.1 }}>Request Revision</div>
<div className="form-group">
@@ -1090,7 +1089,7 @@ export default function TaskDetail() {
)}
{rejectModal && (
<div style={popupOverlayStyle} onClick={() => { if (!rejectSaving) { setRejectModal(false); setRejectNote(''); } }}>
<div style={popupOverlayStyle}>
<div style={{ ...popupSurfaceStyle, width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', lineHeight: 1.1 }}>Reject Task</div>
<div>
@@ -1114,7 +1113,7 @@ export default function TaskDetail() {
)}
{amendModal && (
<div style={popupOverlayStyle} onClick={() => { setAmendModal(false); setAmendForm({ description: '' }); setAmendFiles([]); }}>
<div style={popupOverlayStyle}>
<div style={{ ...popupSurfaceStyle, width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', lineHeight: 1.1 }}>Amend Request</div>
<div>
+60 -92
View File
@@ -68,7 +68,7 @@ function TasksStatsRow({ tasks = [] }) {
const open = Math.max(total - completed, 0);
const pct = (count) => total > 0 ? `${Math.round((count / total) * 100)}% of total` : '0% of total';
return (
<div className="dash-stat-grid" style={{ gridTemplateColumns: '1fr 1fr 1fr 1fr 1fr 1fr' }}>
<div className="tasks-stat-grid">
<TaskStatCard label="To Do" value={toDo} sub={pct(toDo)} iconBg="color-mix(in srgb, var(--violet) 15%, transparent)" iconColor="var(--violet)" iconPath={TASK_STAT_ICONS.todo} />
<TaskStatCard label="In Progress" value={inProgress} sub={pct(inProgress)} iconBg="color-mix(in srgb, var(--accent) 15%, transparent)" iconColor="var(--accent)" iconPath={TASK_STAT_ICONS.progress} />
<TaskStatCard label="On Hold" value={onHold} sub={pct(onHold)} iconBg="color-mix(in srgb, var(--danger) 15%, transparent)" iconColor="var(--danger)" iconPath={TASK_STAT_ICONS.hold} />
@@ -79,55 +79,12 @@ function TasksStatsRow({ tasks = [] }) {
);
}
function TasksPageShell({ statsTasks = [], projects = [], onAddProject = null, children }) {
const [projSortKey, setProjSortKey] = useState('status');
const [projSortDir, setProjSortDir] = useState('asc');
const [projTab, setProjTab] = useState('active');
const toggleProjSort = (col) => {
if (projSortKey === col) setProjSortDir(d => d === 'asc' ? 'desc' : 'asc');
else { setProjSortKey(col); setProjSortDir('asc'); }
};
const completedStatuses = new Set(['completed', 'cancelled', 'archived', 'done']);
const projectTabs = [
{ id: 'all', label: 'All' },
{ id: 'active', label: 'Active' },
{ id: 'completed', label: 'Completed' },
];
const visibleProjects = projects.filter(p => {
if (projTab === 'all') return true;
const done = completedStatuses.has((p?.status || '').toLowerCase());
return projTab === 'completed' ? done : !done;
});
const projectTabCounts = {
all: projects.length,
active: projects.filter((p) => !completedStatuses.has((p?.status || '').toLowerCase())).length,
completed: projects.filter((p) => completedStatuses.has((p?.status || '').toLowerCase())).length,
};
const sortedProjects = [...visibleProjects].sort((a, b) => {
if (projSortKey === 'status') {
const ra = completedStatuses.has((a.status || '').toLowerCase()) ? 1 : 0;
const rb = completedStatuses.has((b.status || '').toLowerCase()) ? 1 : 0;
if (ra !== rb) return projSortDir === 'asc' ? ra - rb : rb - ra;
}
const av = (a.name || '').toLowerCase();
const bv = (b.name || '').toLowerCase();
return projSortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
});
const projectCompletionPct = (project) => {
const pt = statsTasks.filter(t => t?.project_id === project?.id);
if (pt.length > 0) {
return Math.round((pt.filter(t => ['client_approved', 'invoiced', 'paid'].includes(t?.status)).length / pt.length) * 100);
}
const s = (project?.status || '').toLowerCase();
if (s === 'completed' || s === 'done') return 100;
if (s === 'cancelled' || s === 'archived') return 0;
return 35;
};
function TasksPageShell({ statsTasks = [], children }) {
return (
<div style={{ minWidth: 0, flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div className="tasks-shell" style={{ minWidth: 0, flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<TasksStatsRow tasks={statsTasks} />
<div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div className="tasks-body-row" style={{ display: 'flex', flex: 1, minHeight: 0 }}>
<div className="tasks-body-col" style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
{children}
</div>
</div>
@@ -140,6 +97,8 @@ export default function RequestsPage() {
const isTeam = currentUser?.role === 'team';
const isExternal = currentUser?.role === 'external';
const isClient = currentUser?.role === 'client';
// Company column only meaningful when the user spans multiple companies (team sees all).
const showCompanyCol = isTeam || (currentUser?.companies?.length || 0) > 1;
const { refreshKey } = useLiveRefresh(['tasks', 'projects', 'submissions']);
@@ -163,6 +122,7 @@ export default function RequestsPage() {
const [filterStatus, setFilterStatus] = useState('all');
const [filterRevision, setFilterRevision] = useState('all');
const [filterType, setFilterType] = useState('all');
const [filterAssigned, setFilterAssigned] = useState('all');
const [filterProject, setFilterProject] = useState('');
const { sortKey, sortDir, toggle, sort } = useSortable('status', 'asc');
@@ -265,7 +225,7 @@ export default function RequestsPage() {
}
loadTasksPage();
return () => { cancelled = true; };
}, [isTeam, isExternal, isClient, currentUser?.id, refreshKey]); // eslint-disable-line react-hooks/exhaustive-deps
}, [isTeam, isExternal, isClient, currentUser?.id, refreshKey]);
const handleAddRequest = async (formData, files, existingProjects) => {
if (addSaving) return;
@@ -290,7 +250,6 @@ export default function RequestsPage() {
console.error('Request folder sync failed:', folderError);
}
if (files.length > 0) {
const taskCompany = companies?.find(c => c.id === formData.companyId);
setShowAddForm(false);
setUploadProgress({ active: true, label: 'Uploading files…', percent: 0 });
for (let fi = 0; fi < files.length; fi++) {
@@ -456,7 +415,7 @@ export default function RequestsPage() {
return cos.slice().sort((a, b) => a.name.localeCompare(b.name));
}
return companies.slice().sort((a, b) => (a.name || '').localeCompare(b.name || ''));
}, [isClient, companies, currentUser]); // eslint-disable-line react-hooks/exhaustive-deps
}, [isClient, companies, currentUser]);
// Normalize all tasks to a common row shape for unified table render
const allRows = useMemo(() => {
@@ -484,7 +443,7 @@ export default function RequestsPage() {
submittedAt: derived.latestActivityAt,
};
}).filter(Boolean);
}, [tasks, submissions, deliveries, isClient]); // eslint-disable-line react-hooks/exhaustive-deps
}, [tasks, submissions, deliveries, isClient]);
const filteredRows = useMemo(() => {
return allRows
@@ -517,6 +476,8 @@ export default function RequestsPage() {
if (filterRevision === 'new') tabRows = tabRows.filter(r => Number(r.version || 0) === 0);
else if (filterRevision === 'revision') tabRows = tabRows.filter(r => Number(r.version || 0) >= 1);
if (filterType !== 'all') tabRows = tabRows.filter(r => (r.serviceType || '') === filterType);
if (filterAssigned === 'unassigned') tabRows = tabRows.filter(r => !r.assignedTo);
else if (filterAssigned !== 'all') tabRows = tabRows.filter(r => r.assignedTo === filterAssigned);
const REVISION_FILTER_OPTIONS = [
{ value: 'all', label: 'All' },
@@ -545,6 +506,14 @@ export default function RequestsPage() {
...filterableCompanies.map(c => ({ value: c.id, label: c.name })),
];
const ASSIGNED_FILTER_OPTIONS = [
{ value: 'all', label: 'All Assignees' },
{ value: 'unassigned', label: 'Unassigned' },
...[...new Map(tabRowsBase.filter(r => r.assignedTo).map(r => [r.assignedTo, r.assignedName || '—'])).entries()]
.sort((a, b) => a[1].localeCompare(b[1]))
.map(([id, name]) => ({ value: id, label: name })),
];
const STATUS_FILTER_OPTIONS = activeTab === 'completed'
? [
{ value: 'all', label: 'All Statuses' },
@@ -574,13 +543,6 @@ export default function RequestsPage() {
return '';
});
const projectOptions = useMemo(() => {
if (!isExternal) return [];
return [...new Map(
allRows.map(r => projects.find(p => p.id === r.projectId)).filter(Boolean).map(p => [p.id, p])
).values()];
}, [isExternal, allRows, projects]); // eslint-disable-line react-hooks/exhaustive-deps
const renderRow = (row) => {
const project = projects.find(p => p.id === row.projectId);
@@ -589,13 +551,13 @@ export default function RequestsPage() {
<td style={{ ...TASK_TABLE_TD_BASE }}>
<StatusBadge status={row.status} />
</td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>
<td className="tcol-rev" style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>
{`R${String(row.version).padStart(2, '0')}`}
</td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)' }}>
<Link to={`/tasks/${row.id}`} className="table-link">{row.title || '—'}</Link>
</td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)' }}>
<td className="tcol-project" style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)' }}>
{project ? <Link to={`/projects/${project.id}`} className="table-link">{project.name}</Link> : '—'}
</td>
<td style={{ ...TASK_TABLE_TD_BASE, textAlign: 'center' }}>
@@ -607,15 +569,17 @@ export default function RequestsPage() {
return <div title="Unassigned" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, borderRadius: '50%', overflow: 'hidden', verticalAlign: 'middle', flexShrink: 0, background: 'rgba(255,255,255,0.08)' }}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.3)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg></div>;
})()}
</td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>
<td className="tcol-type" style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>
{row.serviceType}
</td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>
<td className="tcol-due" style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>
{fmtShortDate(row.deadline, 'Not specified')}
</td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)' }}>
{showCompanyCol && (
<td className="tcol-company" style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)' }}>
{project?.company?.name || '—'}
</td>
)}
</tr>
);
};
@@ -624,21 +588,13 @@ export default function RequestsPage() {
if (loadError) return <Layout><div style={{ padding: 24 }}><p style={{ color: 'var(--text-muted)', marginBottom: 12 }}>Failed to load. Check your connection and try again.</p><button className="btn btn-outline" onClick={() => window.location.reload()}>Retry</button></div></Layout>;
const filteredStatTasks = filteredRows.map(r => tasks.find(t => t.id === r.id)).filter(Boolean);
const filteredProjectsShell = filterCompany ? projects.filter(p => p.company_id === filterCompany) : projects;
return (
<Layout>
<TasksPageShell
statsTasks={filteredStatTasks}
projects={filteredProjectsShell}
onAddProject={(isTeam || isClient) ? () => {
setAddProjectForm(f => ({ ...f, companyId: !isTeam && filterableCompanies.length === 1 ? filterableCompanies[0].id : f.companyId }));
setShowAddProjectForm(true); setAddProjectError('');
} : null}
>
<TasksPageShell statsTasks={filteredStatTasks}>
{/* Add project modal */}
{showAddProjectForm && (
<div style={popupOverlayStyle} onClick={() => { setShowAddProjectForm(false); setAddProjectForm({ name: '', companyId: '' }); setAddProjectError(''); }}>
<div style={popupOverlayStyle}>
<div style={PROJECT_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 14 }}>
<div style={TASK_MODAL_TITLE_STYLE}>Add Project</div>
@@ -667,7 +623,7 @@ export default function RequestsPage() {
{/* Add task modal */}
{showAddForm && (
<div style={popupOverlayStyle} onClick={() => { setShowAddForm(false); setAddFormKey(k => k + 1); setAddError(''); }}>
<div style={popupOverlayStyle}>
<div style={TASK_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 14 }}>
<div style={TASK_MODAL_TITLE_STYLE}>{isTeam ? 'Add Task' : 'New Task'}</div>
@@ -689,9 +645,9 @@ export default function RequestsPage() {
)}
{/* Controls bar: tabs left, filters + actions right */}
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10, flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, rowGap: 8, marginBottom: 10, flexShrink: 0, flexWrap: 'wrap' }}>
{tabs.map(t => (
<button key={t.id} onClick={() => { setActiveTab(t.id); setFilterStatus('all'); setFilterRevision('all'); setFilterType('all'); }} className={`section-tab-btn${activeTab === t.id ? ' is-active' : ''}`}>
<button key={t.id} onClick={() => { setActiveTab(t.id); setFilterStatus('all'); setFilterRevision('all'); setFilterType('all'); setFilterAssigned('all'); }} className={`section-tab-btn${activeTab === t.id ? ' is-active' : ''}`}>
{t.label}
{t.id !== 'all' && tabCounts[t.id] > 0 && (
<span style={{ marginLeft: 5, fontSize: 12, fontWeight: 600, color: activeTab === t.id ? 'var(--accent)' : 'var(--text-muted)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 10, padding: '3px 8px' }}>
@@ -708,18 +664,18 @@ export default function RequestsPage() {
</div>
{/* Task table */}
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', background: 'var(--card-bg)', borderRadius: 8, border: 'var(--card-border)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', padding: '18px 21px' }}>
<div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<div className="tasks-table-card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', background: 'var(--card-bg)', borderRadius: 8, border: 'var(--card-border)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', padding: '18px 21px' }}>
<div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden tasks-table-scroll" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table className="table-sticky-head table-no-row-hover" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
<colgroup>
<col style={{ width: '11%' }} />
<col style={{ width: '7%' }} />
<col className="tcol-rev" style={{ width: '7%' }} />
<col style={{ width: '24%' }} />
<col style={{ width: '15%' }} />
<col className="tcol-project" style={{ width: '15%' }} />
<col style={{ width: '9%' }} />
<col style={{ width: '12%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '12%' }} />
<col className="tcol-type" style={{ width: '12%' }} />
<col className="tcol-due" style={{ width: '10%' }} />
{showCompanyCol && <col className="tcol-company" style={{ width: '12%' }} />}
</colgroup>
<thead>
<tr>
@@ -734,7 +690,7 @@ export default function RequestsPage() {
<FilterDropdown value={filterStatus} onChange={setFilterStatus} options={STATUS_FILTER_OPTIONS} />
</span>
</th>
<th style={{ ...TASK_TABLE_TH_STYLE, whiteSpace: 'nowrap' }}>
<th className="tcol-rev" style={{ ...TASK_TABLE_TH_STYLE, whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span onClick={() => toggle('revision')} style={{ cursor: 'pointer', userSelect: 'none' }}>
R#
@@ -745,11 +701,11 @@ export default function RequestsPage() {
<FilterDropdown value={filterRevision} onChange={setFilterRevision} options={REVISION_FILTER_OPTIONS} />
</span>
</th>
<SortTh col="title" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TASK_TABLE_TH_STYLE}>Name</SortTh>
<th style={{ ...TASK_TABLE_TH_STYLE, whiteSpace: 'nowrap' }}>
<SortTh col="title" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TASK_TABLE_TH_STYLE}>Project</SortTh>
<th className="tcol-project" style={{ ...TASK_TABLE_TH_STYLE, whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span onClick={() => toggle('project')} style={{ cursor: 'pointer', userSelect: 'none' }}>
Project
Client
<span style={{ marginLeft: 4, opacity: sortKey === 'project' ? 0.85 : 0.2, fontSize: 9 }}>
{sortKey === 'project' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}
</span>
@@ -757,8 +713,18 @@ export default function RequestsPage() {
<FilterDropdown value={filterProject} onChange={setFilterProject} options={PROJECT_FILTER_OPTIONS} />
</span>
</th>
<SortTh col="assigned" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Assigned</SortTh>
<th style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center', whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span onClick={() => toggle('assigned')} style={{ cursor: 'pointer', userSelect: 'none' }}>
Assigned
<span style={{ marginLeft: 4, opacity: sortKey === 'assigned' ? 0.85 : 0.2, fontSize: 9 }}>
{sortKey === 'assigned' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}
</span>
</span>
<FilterDropdown value={filterAssigned} onChange={setFilterAssigned} options={ASSIGNED_FILTER_OPTIONS} />
</span>
</th>
<th className="tcol-type" style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center', whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span onClick={() => toggle('serviceType')} style={{ cursor: 'pointer', userSelect: 'none' }}>
Task Type
@@ -769,8 +735,9 @@ export default function RequestsPage() {
<FilterDropdown value={filterType} onChange={setFilterType} options={TYPE_FILTER_OPTIONS} />
</span>
</th>
<SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Due</SortTh>
<th style={{ ...TASK_TABLE_TH_STYLE, whiteSpace: 'nowrap' }}>
<SortTh col="deadline" className="tcol-due" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Due</SortTh>
{showCompanyCol && (
<th className="tcol-company" style={{ ...TASK_TABLE_TH_STYLE, whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span onClick={() => toggle('company')} style={{ cursor: 'pointer', userSelect: 'none' }}>
Company
@@ -781,11 +748,12 @@ export default function RequestsPage() {
<FilterDropdown value={filterCompany} onChange={(v) => { setFilterCompany(v); setFilterProject(''); }} options={COMPANY_FILTER_OPTIONS} />
</span>
</th>
)}
</tr>
</thead>
<tbody>
{sortedRows.length === 0 ? (
<tr><td colSpan={8} style={{ textAlign: 'center', padding: '40px 0', color: 'var(--text-muted)', fontSize: 13 }}>No tasks</td></tr>
<tr><td colSpan={showCompanyCol ? 8 : 7} style={{ textAlign: 'center', padding: '40px 0', color: 'var(--text-muted)', fontSize: 13 }}>No tasks</td></tr>
) : sortedRows.map(renderRow)}
</tbody>
</table>
+3 -4
View File
@@ -10,9 +10,11 @@ import { supabase } from '../../lib/supabase';
import { generateInvoicePDF, generateReceiptPDF } from '../../lib/invoice';
import { useAuth } from '../../context/AuthContext';
import { useSortable } from '../../hooks/useSortable';
import InvoiceDetailPopup, { POPUP_FIELD_LABEL } from '../../components/InvoiceDetailPopup';
import InvoiceDetailPopup from '../../components/InvoiceDetailPopup';
import { POPUP_FIELD_LABEL } from '../../lib/popupStyles';
import InvoicePopupTable from '../../components/InvoicePopupTable';
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
const invoiceStatusLabel = (status) => {
if (status === 'sent') return 'Invoiced';
@@ -103,8 +105,6 @@ function ClientInvoiceModal({ invoice, onClose }) {
title={invoice.invoice_number}
subtitle={invoice.bill_to || company?.name}
headerRight={<StatusBadge status={statusColor[invoice.status] || 'not_started'} label={`${invoiceStatusLabel(invoice.status)}${isOverdue ? ' · Overdue' : ''}`} />}
onClose={onClose}
blockClose={Boolean(downloading)}
metaContent={<>
<div><div style={F}>Invoice Date</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{invoice.invoice_date ? new Date(invoice.invoice_date).toLocaleDateString() : '—'}</div></div>
<div><div style={F}>Due Date</div><div style={{ fontSize: 13, color: isOverdue ? 'var(--danger)' : 'var(--text-primary)' }}>{invoice.due_date ? new Date(invoice.due_date).toLocaleDateString() : '—'}</div></div>
@@ -167,7 +167,6 @@ export default function MyInvoices() {
return () => document.removeEventListener('mousedown', onDocClick);
}, [filterMenuOpen]);
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const invoiceYears = [...new Set(invoices.map(i => i.invoice_date?.slice(0, 4)).filter(Boolean))].sort((a, b) => b.localeCompare(a));
const availableCompanyOptions = (currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : []))
.filter(company => company?.id && invoices.some(inv => inv.company_id === company.id))
+3 -30
View File
@@ -9,8 +9,8 @@ import { readPageCache, writePageCache } from '../../lib/pageCache';
import { useSortable } from '../../hooks/useSortable';
import { isCompletedVersionEligible } from '../../lib/invoiceVersionRules';
import SubcontractorInvoiceForm from '../../components/SubcontractorInvoiceForm';
import { popupOverlayStyle, popupSurfaceStyle } from '../../lib/popupStyles';
import InvoiceDetailPopup, { POPUP_FIELD_LABEL } from '../../components/InvoiceDetailPopup';
import { popupOverlayStyle, popupSurfaceStyle, POPUP_FIELD_LABEL } from '../../lib/popupStyles';
import InvoiceDetailPopup from '../../components/InvoiceDetailPopup';
import InvoicePopupTable from '../../components/InvoicePopupTable';
import { generateSubcontractorPOPDF } from '../../lib/invoice';
@@ -60,10 +60,6 @@ function parseItemVersionNumber(item) {
return match ? Number(match[1]) : 0;
}
function itemVersionLabel(item) {
return `R${String(parseItemVersionNumber(item)).padStart(2, '0')}`;
}
function itemWorkLabel(item) {
return parseItemVersionNumber(item) > 0 ? 'Revision' : 'New';
}
@@ -72,24 +68,6 @@ function cleanItemDescription(item) {
return String(item?.description || '—').replace(/\s*[-]\s*R\d{2}\b/i, '').trim() || '—';
}
async function fetchTaskTypeMap(taskIds) {
const uniqueTaskIds = [...new Set((taskIds || []).filter(Boolean))];
if (uniqueTaskIds.length === 0) return {};
const { data, error } = await supabase
.from('tasks')
.select('id, title, submissions(service_type, type)')
.in('id', uniqueTaskIds);
if (error) throw error;
const next = {};
for (const task of (data || [])) {
const submissions = asArray(task.submissions);
const initial = submissions.find((submission) => submission?.type === 'initial' && submission?.service_type);
const fallback = submissions.find((submission) => submission?.service_type);
next[task.id] = initial?.service_type || fallback?.service_type || task.title || 'Other';
}
return next;
}
function buildPDFArgs(invoice, profile, total, statusOverride) {
return {
po_number: invoice.invoice_number,
@@ -125,7 +103,6 @@ export default function MyInvoices() {
const [viewingInvoice, setViewingInvoice] = useState(null);
const [detailLoading, setDetailLoading] = useState(false);
const [detailError, setDetailError] = useState('');
const [detailTaskTypeMap, setDetailTaskTypeMap] = useState({});
const [submittingInvoice, setSubmittingInvoice] = useState(false);
const [downloadingInvoice, setDownloadingInvoice] = useState(false);
const [downloadingReceipt, setDownloadingReceipt] = useState(false);
@@ -249,8 +226,6 @@ export default function MyInvoices() {
.eq('id', invoiceId)
.single();
if (err || !data) throw err || new Error('Invoice not found.');
const taskTypeMap = await fetchTaskTypeMap(asArray(data.items).map((item) => item.task_id));
setDetailTaskTypeMap(taskTypeMap);
setViewingInvoice(data);
} catch (err) {
setDetailError(err?.message || 'Failed to load invoice.');
@@ -428,7 +403,7 @@ export default function MyInvoices() {
</div>
{showInvoiceForm && (
<div style={popupOverlayStyle} onClick={() => setShowInvoiceForm(false)}>
<div style={popupOverlayStyle}>
<div
style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }}
onClick={(e) => e.stopPropagation()}
@@ -469,8 +444,6 @@ export default function MyInvoices() {
title={inv?.invoice_number || 'Subcontractor Invoice'}
subtitle={`${currentUser?.name || 'Subcontractor'}${currentUser?.email ? ` · ${currentUser.email}` : ''}`}
headerRight={inv ? <StatusBadge status={STATUS_BADGE[inv.status] || 'not_started'} label={invoiceStatus} /> : null}
onClose={() => { if (!busy) { setViewingInvoice(null); setDetailError(''); } }}
blockClose={busy}
loading={detailLoading && !inv}
metaContent={inv ? <>
<div><div style={F}>Created</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{inv.created_at ? new Date(inv.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div></div>
+6 -19
View File
@@ -18,19 +18,6 @@ import { isReviewShadowDescription } from '../../lib/taskVersions';
// Helpers
const ICON_TONES = [
{ bg: 'color-mix(in srgb, var(--accent) 15%, transparent)', color: 'var(--accent)' },
{ bg: 'color-mix(in srgb, var(--positive) 15%, transparent)', color: 'var(--positive)' },
{ bg: 'color-mix(in srgb, var(--info) 15%, transparent)', color: 'var(--info)' },
{ bg: 'color-mix(in srgb, var(--violet) 15%, transparent)', color: 'var(--violet)' },
];
function iconTone(key) {
let h = 0;
for (let i = 0; i < (key || '').length; i++) h = (h * 31 + key.charCodeAt(i)) % ICON_TONES.length;
return ICON_TONES[h];
}
function fmtMoney(n) {
if (Math.abs(n) >= 1000000) return `$${(n / 1000000).toFixed(2)}M`;
if (Math.abs(n) >= 1000) return `$${(n / 1000).toFixed(1)}k`;
@@ -312,7 +299,7 @@ function TasksInProgressCard({ tasks = [] }) {
{rows.length === 0 ? (
<div style={{ minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No tasks in progress</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '55%' }} />
<col style={{ width: '45%' }} />
@@ -402,7 +389,7 @@ function HotItemsCard({ submissions, tasks, isClient = false, isExternal = false
</div>
) : (
<div className="table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
{isExternal ? (
<>
<colgroup>
@@ -486,7 +473,7 @@ function HotItemsCard({ submissions, tasks, isClient = false, isExternal = false
);
}
function TeamPerformanceCard({ tasks, profiles, deliveries, submissions, cardHeight = null }) {
function TeamPerformanceCard({ profiles, deliveries, submissions, cardHeight = null }) {
const navigate = useNavigate();
const profileMap = useMemo(() => {
const m = new Map();
@@ -547,7 +534,7 @@ function TeamPerformanceCard({ tasks, profiles, deliveries, submissions, cardHei
items.push({ task: e.task, kind: e.version === 0 ? 'new' : 'rev', monthKey, person });
});
return items;
}, [submissions, delByVer, delByTask, delAtVer, delAtTask]); // eslint-disable-line react-hooks/exhaustive-deps
}, [submissions, delByVer, delByTask, delAtVer, delAtTask]);
const monthsWithData = useMemo(() => new Set(eligibleItems.map(i => i.monthKey).filter(Boolean)), [eligibleItems]);
const allMonthOpts = useMemo(() => {
@@ -854,7 +841,7 @@ export default function TeamDashboard() {
<Layout>
<div className="dash-stat-grid" style={{ marginBottom: 0 }}>
<DashStatCard label="Open Tasks" value={activeTasks.length} sub="not complete" iconBg="color-mix(in srgb, var(--violet) 15%, transparent)" iconColor="var(--violet)" iconPath={DASH_ICONS.tasks} />
<DashStatCard label="Active Projects" value={activeProjects.length} sub={`${projects.length} total`} iconBg="color-mix(in srgb, var(--info) 15%, transparent)" iconColor="var(--info)" iconPath={DASH_ICONS.projects} />
<DashStatCard label="Active Clients" value={activeProjects.length} sub={`${projects.length} total`} iconBg="color-mix(in srgb, var(--info) 15%, transparent)" iconColor="var(--info)" iconPath={DASH_ICONS.projects} />
<DashStatCard label={card3.label} value={card3.value} sub={card3.sub} iconBg={card3.iconBg} iconColor={card3.iconColor} iconPath={card3.iconPath} />
<DashStatCard label={card4.label} value={card4.value} sub={card4.sub} iconBg={card4.iconBg} iconColor={card4.iconColor} iconPath={card4.iconPath} chartData={card4.chartData} />
</div>
@@ -869,7 +856,7 @@ export default function TeamDashboard() {
<div className="dash-row-trio">
<ActivityFeed events={activityEvents} />
<TasksInProgressCard tasks={inProgressTasks} />
<TeamPerformanceCard tasks={tasks} profiles={allProfiles} deliveries={perfDeliveries} submissions={perfSubmissions} />
<TeamPerformanceCard profiles={allProfiles} deliveries={perfDeliveries} submissions={perfSubmissions} />
</div>
</Layout>
);
+3 -4
View File
@@ -10,7 +10,8 @@ import { generateInvoicePDF, generateReceiptPDF } from '../../lib/invoice';
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
import { withTimeout } from '../../lib/withTimeout';
import { useSortable } from '../../hooks/useSortable';
import InvoiceDetailPopup, { POPUP_FIELD_LABEL } from '../../components/InvoiceDetailPopup';
import InvoiceDetailPopup from '../../components/InvoiceDetailPopup';
import { POPUP_FIELD_LABEL } from '../../lib/popupStyles';
import InvoicePopupTable from '../../components/InvoicePopupTable';
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
@@ -403,8 +404,6 @@ export function TeamInvoiceDetailPanel({
label={`${invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—'}${isOverdue ? ' · Overdue' : ''}`}
/>
) : null}
onClose={onClose}
blockClose={saving || Boolean(generating)}
loading={loading}
metaContent={invoice ? <>
<div>
@@ -574,7 +573,7 @@ export function TeamInvoiceDetailPanel({
<div className="card" style={{ marginBottom: 24 }}>
<div className="card-title">Line Items</div>
<div className="table-wrapper" style={{ border: 'none' }}>
<table>
<table className="table-sticky-head">
<thead>
<tr>
<SortTh col="type" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ width: 100 }}>Type</SortTh>
+106 -310
View File
@@ -4,42 +4,27 @@ import { DashboardBanner } from '../../lib/dashboardBanner';
import { useLocation, useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout';
import SortTh from '../../components/SortTh';
import FilterDropdown from '../../components/FilterDropdown';
import StatusBadge from '../../components/StatusBadge';
import { useSortable } from '../../hooks/useSortable';
import { useActionLock } from '../../hooks/useActionLock';
import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext';
import { readPageCache, writePageCache } from '../../lib/pageCache';
import { exportCPAPackage, generateSubcontractorPOPDF, generateInvoicePDF } from '../../lib/invoice';
import { generateSubcontractorPOPDF, generateInvoicePDF } from '../../lib/invoice';
import { withTimeout } from '../../lib/withTimeout';
import LoadingButton from '../../components/LoadingButton';
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
import { popupOverlayStyle, popupSurfaceStyle } from '../../lib/popupStyles';
import { popupOverlayStyle, popupSurfaceStyle, POPUP_FIELD_LABEL } from '../../lib/popupStyles';
import FileAttachment from '../../components/FileAttachment';
import { isReviewShadowDescription, pickInitialServiceType } from '../../lib/taskVersions';
import { getRevisionChargeQuantity, isCompletedVersionEligible, isInitialVersionEligible } from '../../lib/invoiceVersionRules';
import { TeamInvoiceDetailPanel } from './TeamInvoiceDetail';
import InvoiceDetailPopup, { POPUP_FIELD_LABEL } from '../../components/InvoiceDetailPopup';
import InvoiceDetailPopup from '../../components/InvoiceDetailPopup';
import InvoicePopupTable from '../../components/InvoicePopupTable';
const CATEGORIES = ['Software', 'Contractor', 'Advertising', 'Office', 'Travel', 'Meals', 'Equipment', 'Other'];
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
const poStatusColor = {
draft: 'not_started',
sent: 'in_progress',
approved: 'client_approved',
ready_to_pay: 'in_progress',
paid: 'client_approved',
cancelled: 'needs_revision',
};
const poStatusLabel = {
draft: 'Draft',
sent: 'Sent',
approved: 'Approved',
ready_to_pay: 'Ready to Pay',
paid: 'Paid',
cancelled: 'Cancelled',
};
const invoiceStatusBadgeLabel = (status) => {
if (status === 'sent') return 'Invoiced';
return status ? status.charAt(0).toUpperCase() + status.slice(1) : '—';
@@ -125,10 +110,6 @@ function parseSubInvoiceItemVersionNumber(item) {
return match ? Number(match[1]) : 0;
}
function subInvoiceItemVersionLabel(item) {
return `R${String(parseSubInvoiceItemVersionNumber(item)).padStart(2, '0')}`;
}
function subInvoiceItemWorkLabel(item) {
return parseSubInvoiceItemVersionNumber(item) > 0 ? 'Revision' : 'New';
}
@@ -137,30 +118,6 @@ function cleanSubInvoiceItemDescription(item) {
return String(item?.description || '—').replace(/\s*[-]\s*R\d{2}\b/i, '').trim() || '—';
}
function asArray(value) {
if (Array.isArray(value)) return value;
if (value == null) return [];
return typeof value === 'object' ? [value] : [];
}
async function fetchSubInvoiceTaskTypeMap(taskIds) {
const uniqueTaskIds = [...new Set((taskIds || []).filter(Boolean))];
if (uniqueTaskIds.length === 0) return {};
const { data, error } = await supabase
.from('tasks')
.select('id, title, submissions(service_type, type)')
.in('id', uniqueTaskIds);
if (error) throw error;
const next = {};
for (const task of (data || [])) {
const submissions = asArray(task.submissions);
const initial = submissions.find((submission) => submission?.type === 'initial' && submission?.service_type);
const fallback = submissions.find((submission) => submission?.service_type);
next[task.id] = initial?.service_type || fallback?.service_type || task.title || 'Other';
}
return next;
}
function CurrencyInput({ value, onChange, placeholder = '0.00', required = false, min = '0', step = '0.01' }) {
return (
<div style={FINANCE_MODAL_AMOUNT_FIELD_STYLE}>
@@ -242,9 +199,6 @@ export default function Invoices() {
const { sortKey: invSortKey, sortDir: invSortDir, toggle: invToggle, sort: invSort } = useSortable('invoice_date', 'desc');
const { sortKey: expSortKey, sortDir: expSortDir, toggle: expToggle, sort: expSort } = useSortable('date', 'desc');
const { sortKey: subSortKey, sortDir: subSortDir, toggle: subToggle, sort: subSort } = useSortable('submitted_at');
const { sortKey: ovExpKey, sortDir: ovExpDir, toggle: ovExpToggle } = useSortable('date');
const { sortKey: ovSubKey, sortDir: ovSubDir, toggle: ovSubToggle } = useSortable('date');
const { sortKey: ovInvKey, sortDir: ovInvDir, toggle: ovInvToggle } = useSortable('invoice_date');
const { sortKey: subPopupKey, sortDir: subPopupDir, toggle: subPopupToggle, sort: subPopupSort } = useSortable('description');
const [filterCompany, setFilterCompany] = useState('');
const initMonth = () => { const n = new Date(); return { month: n.getMonth(), year: n.getFullYear() }; };
@@ -252,7 +206,6 @@ export default function Invoices() {
const [ovMonth2, setOvMonth2] = useState(initMonth);
const [ovMonth3, setOvMonth3] = useState(initMonth);
const [exportYear, setExportYear] = useState(new Date().getFullYear());
const [exporting, setExporting] = useState(false);
const [expenses, setExpenses] = useState([]);
const [expensesLoading, setExpensesLoading] = useState(true);
@@ -266,24 +219,17 @@ export default function Invoices() {
const [expensePreviewUrl, setExpensePreviewUrl] = useState(null);
const [expenseDetailEditing, setExpenseDetailEditing] = useState(false);
const [expYearFilter, setExpYearFilter] = useState(String(new Date().getFullYear()));
const [expYearMenuOpen, setExpYearMenuOpen] = useState(false);
const expYearMenuRef = useRef(null);
const [invYearFilter, setInvYearFilter] = useState(String(new Date().getFullYear()));
const [invCompanyFilter, setInvCompanyFilter] = useState('all');
const [invYearMenuOpen, setInvYearMenuOpen] = useState(false);
const invYearMenuRef = useRef(null);
const [subcontractorPOs, setSubcontractorPOs] = useState([]);
const [subcontractorLoading, setSubcontractorLoading] = useState(true);
const [subcontractorError, setSubcontractorError] = useState('');
const [selectedSubcontractorPOId, setSelectedSubcontractorPOId] = useState('');
const [subWhoFilter, setSubWhoFilter] = useState('all');
const [subStatusFilter, setSubStatusFilter] = useState('all');
const [subYearFilter, setSubYearFilter] = useState('all');
const [subInvoices, setSubInvoices] = useState([]);
const [subInvoicesLoading, setSubInvoicesLoading] = useState(true);
const [subInvoicesError, setSubInvoicesError] = useState('');
const [markingPaid, setMarkingPaid] = useState('');
const [viewingSubInvoice, setViewingSubInvoice] = useState(null);
const [viewingSubInvoiceTaskTypeMap, setViewingSubInvoiceTaskTypeMap] = useState({});
const [viewingInvoice, setViewingInvoice] = useState(null);
const [expandedSubInvoiceId, setExpandedSubInvoiceId] = useState(null);
// New Invoice form
const [showInvoiceForm, setShowInvoiceForm] = useState(false);
@@ -301,30 +247,12 @@ export default function Invoices() {
const guard = useActionLock();
const [invLoadingTasks, setInvLoadingTasks] = useState(false);
const invDragItem = useRef(null);
const pageLoading = loading || expensesLoading || subcontractorLoading || subInvoicesLoading;
const pageLoading = loading || expensesLoading || subInvoicesLoading;
useEffect(() => {
supabase.from('companies').select('id, name, contact_email').order('name').then(({ data }) => setInvCompanies(data || []));
}, []);
useEffect(() => {
let cancelled = false;
async function loadSubInvoiceTaskTypes() {
if (!viewingSubInvoice?.items?.length) {
setViewingSubInvoiceTaskTypeMap({});
return;
}
try {
const map = await fetchSubInvoiceTaskTypeMap(asArray(viewingSubInvoice.items).map((item) => item.task_id));
if (!cancelled) setViewingSubInvoiceTaskTypeMap(map);
} catch {
if (!cancelled) setViewingSubInvoiceTaskTypeMap({});
}
}
loadSubInvoiceTaskTypes();
return () => { cancelled = true; };
}, [viewingSubInvoice]);
useEffect(() => {
if (!invCompanyId) { setInvUnbilledTasks([]); setInvUnbilledRevisions([]); setInvPriceList([]); setInvItems([invNewItem()]); setInvBillTo(''); setInvEmail(''); setInvRecipients([]); return; }
const co = invCompanies.find(c => c.id === invCompanyId);
@@ -456,13 +384,7 @@ export default function Invoices() {
useEffect(() => {
async function loadExpenses() {
const [{ data, error }, { data: purchaseOrders, error: purchaseOrdersError }] = await Promise.all([
supabase.from('expenses').select('*').order('date', { ascending: false }),
supabase
.from('subcontractor_payments')
.select('*, profile:profiles!subcontractor_payments_profile_id_fkey(id, name, email), project:projects(id, name, company:companies(name)), items:subcontractor_po_items(*, task:tasks(id, title))')
.order('date', { ascending: false }),
]);
const { data, error } = await supabase.from('expenses').select('*').order('date', { ascending: false });
if (error) {
console.error('Failed to load expenses:', error);
setExpensesError(error.message || 'Failed to load expenses.');
@@ -471,34 +393,11 @@ export default function Invoices() {
setExpenses(data || []);
setExpensesError('');
}
if (purchaseOrdersError) {
console.error('Failed to load subcontractor POs:', purchaseOrdersError);
setSubcontractorError(purchaseOrdersError.message || 'Failed to load subcontractor POs.');
setSubcontractorPOs([]);
} else {
setSubcontractorPOs(purchaseOrders || []);
setSubcontractorError('');
}
setExpensesLoading(false);
setSubcontractorLoading(false);
}
loadExpenses();
}, []);
useEffect(() => {
if (!expYearMenuOpen) return;
const handler = e => { if (expYearMenuRef.current && !expYearMenuRef.current.contains(e.target)) setExpYearMenuOpen(false); };
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [expYearMenuOpen]);
useEffect(() => {
if (!invYearMenuOpen) return;
const handler = e => { if (invYearMenuRef.current && !invYearMenuRef.current.contains(e.target)) setInvYearMenuOpen(false); };
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [invYearMenuOpen]);
useEffect(() => {
setExpensePreviewUrl(null);
setExpenseDetailEditing(false);
@@ -593,37 +492,6 @@ export default function Invoices() {
win.location.href = data.signedUrl;
};
const updateSubcontractorPO = async (po, updates, errorLabel = 'Failed to update PO') => {
const { data, error } = await supabase
.from('subcontractor_payments')
.update(updates)
.eq('id', po.id)
.select('*, profile:profiles!subcontractor_payments_profile_id_fkey(id, name, email), project:projects(id, name, company:companies(name)), items:subcontractor_po_items(*, task:tasks(id, title))')
.single();
if (error) {
alert(`${errorLabel}: ${error.message}`);
return null;
}
if (data) {
setSubcontractorPOs(prev => prev.map(row => row.id === po.id ? data : row));
setSelectedSubcontractorPOId(current => current === po.id ? data.id : current);
}
return data;
};
const handleSendSubcontractorPO = async (po) => {
await updateSubcontractorPO(po, { status: 'sent', sent_at: new Date().toISOString() }, 'Failed to send PO');
};
const handleReadyToPaySubcontractorPO = (po) => {
updateSubcontractorPO(po, { status: 'ready_to_pay' }, 'Failed to mark ready to pay');
};
const handleMarkSubcontractorPaid = async (po) => {
const paidAt = new Date().toISOString().slice(0, 10);
updateSubcontractorPO(po, { status: 'paid', paid_at: paidAt }, 'Failed to mark as paid');
};
const handleMarkSubInvoicePaid = async (invoice) => {
setMarkingPaid(invoice.id);
try {
@@ -724,68 +592,6 @@ export default function Invoices() {
}
};
const handleReopenSubcontractorPO = async (po) => {
updateSubcontractorPO(po, { status: 'draft', paid_at: null, cancelled_at: null }, 'Failed to reopen PO');
};
const handleCancelSubcontractorPO = async (po) => {
if (!window.confirm(`Cancel ${po.po_number || 'this PO'}?`)) return;
updateSubcontractorPO(po, { status: 'cancelled', cancelled_at: new Date().toISOString() }, 'Failed to cancel PO');
};
const handleDeleteSubcontractorPO = async (id) => {
if (!window.confirm('Delete this subcontractor PO?')) return;
await supabase.from('subcontractor_payments').delete().eq('id', id);
setSubcontractorPOs(prev => prev.filter(po => po.id !== id));
setSelectedSubcontractorPOId(current => current === id ? '' : current);
};
const handleDownloadSubcontractorPO = async (po) => {
try {
await generateSubcontractorPOPDF(po);
} catch (error) {
console.error('Failed to download subcontractor PO:', error);
alert(`Failed to download PO: ${error.message || 'unknown error'}`);
}
};
const handleCPAExport = async () => {
setExporting(true);
try {
const yearInvoices = invoices.filter(inv =>
inv.status === 'paid' && new Date(inv.invoice_date).getFullYear() === exportYear
);
const yearExpenses = expenses.filter(exp =>
new Date(exp.date).getFullYear() === exportYear
);
const yearSubcontractorExpenses = subcontractorPOs
.filter(po => po.status === 'paid' && new Date(po.paid_at || po.date).getFullYear() === exportYear)
.map(po => ({
date: po.paid_at || po.date,
category: 'Contractor',
description: `Subcontractor: ${po.profile?.name || 'External'}${po.items?.length ? po.items.map(item => item.description).join('; ') : po.description}`,
amount: po.amount,
notes: [po.po_number, po.project?.name, po.notes || po.profile?.email || ''].filter(Boolean).join(' · '),
}));
const exportExpenses = [...yearExpenses, ...yearSubcontractorExpenses];
if (!yearInvoices.length && !exportExpenses.length) {
alert(`No data found for ${exportYear}.`);
return;
}
const { data: allItems } = yearInvoices.length ? await supabase
.from('invoice_items')
.select('invoice_id, description')
.in('invoice_id', yearInvoices.map(i => i.id)) : { data: [] };
const itemsByInvoice = (allItems || []).reduce((acc, item) => {
(acc[item.invoice_id] = acc[item.invoice_id] || []).push(item);
return acc;
}, {});
await exportCPAPackage(yearInvoices, itemsByInvoice, exportExpenses, exportYear);
} finally {
setExporting(false);
}
};
const paidYears = [...new Set(
invoices.filter(i => i.status === 'paid').map(i => new Date(i.invoice_date).getFullYear())
)].sort((a, b) => b - a);
@@ -797,45 +603,12 @@ export default function Invoices() {
return true;
});
const totals = {
all: invoices.reduce((s, i) => s + Number(i.total), 0),
draft: invoices.filter(i => i.status === 'draft').reduce((s, i) => s + Number(i.total), 0),
sent: invoices.filter(i => i.status === 'sent').reduce((s, i) => s + Number(i.total), 0),
paid: invoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total), 0),
netReceived: invoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total) - Number(i.stripe_fee || 0), 0),
};
const expenseYears = [...new Set(expenses.map(e => e.date?.slice(0, 4)).filter(Boolean))].sort((a, b) => b - a);
const filteredExpenses = expenses.filter(e => {
if (expYearFilter !== 'all' && e.date?.slice(0, 4) !== expYearFilter) return false;
if (expenseFilter !== 'all' && e.category !== expenseFilter) return false;
return true;
});
const paidSubcontractorPOs = subcontractorPOs.filter(po => po.status === 'paid');
const payableSubcontractorPOs = subcontractorPOs.filter(po => ['approved', 'ready_to_pay'].includes(po.status));
const selectedSubcontractorPO = subcontractorPOs.find(po => po.id === selectedSubcontractorPOId);
const subInvoiceItemTotal = (inv) => (inv.items || []).reduce((a, x) => a + Number(x.unit_price || 0) * Number(x.quantity || 1), 0);
const paidSubInvoices = subInvoices.filter(i => i.status === 'paid');
const totalPaidSubInvoices = paidSubInvoices.reduce((s, i) => s + subInvoiceItemTotal(i), 0);
const totalPaidSubcontractors = paidSubcontractorPOs.reduce((s, po) => s + Number(po.amount), 0) + totalPaidSubInvoices;
const totalPayableSubcontractorPOs = payableSubcontractorPOs.reduce((s, po) => s + Number(po.amount), 0);
const totalPayableSubInvoices = subInvoices.filter(i => i.status === 'submitted').reduce((s, i) => s + subInvoiceItemTotal(i), 0);
const totalPayableSubcontractors = totalPayableSubcontractorPOs + totalPayableSubInvoices;
const payableSubcontractorCount = payableSubcontractorPOs.length + subInvoices.filter(i => i.status === 'submitted').length;
const totalExpenses = filteredExpenses.reduce((s, e) => s + Number(e.amount), 0);
const currentYear = new Date().getFullYear();
const yearExpenses = expenses.filter(e => new Date(e.date).getFullYear() === currentYear);
const currentYearExpenseTotal = yearExpenses.reduce((s, e) => s + Number(e.amount), 0);
const currentYearPaidSubcontractorPOs = paidSubcontractorPOs
.filter(po => new Date(po.paid_at || po.date).getFullYear() === currentYear)
.reduce((s, po) => s + Number(po.amount), 0);
const currentYearPaidSubInvoices = paidSubInvoices
.filter(i => new Date(i.paid_at).getFullYear() === currentYear)
.reduce((s, i) => s + subInvoiceItemTotal(i), 0);
const currentYearTotalExpenses = currentYearExpenseTotal + currentYearPaidSubcontractorPOs + currentYearPaidSubInvoices;
const revenue = totals.paid;
const profit = totals.netReceived - expenses.reduce((s, e) => s + Number(e.amount), 0) - totalPaidSubcontractors;
const chartYear = exportYear;
const chartData = useMemo(() => MONTHS.map((month, mi) => {
const paidInvs = invoices.filter(inv => inv.status === 'paid' && new Date(inv.invoice_date).getFullYear() === chartYear && new Date(inv.invoice_date).getMonth() === mi);
@@ -921,8 +694,7 @@ export default function Invoices() {
);
})()}
<div style={{ display: 'grid', gridTemplateColumns: '7fr 3fr', gap: 24, marginTop: 24, marginBottom: 10, flexShrink: 0, alignItems: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, minHeight: 'var(--btn-height)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 24, marginBottom: 10, flexShrink: 0, minHeight: 'var(--btn-height)' }}>
{[
{ id: 'overview', label: 'Overview' },
{ id: 'expenses', label: 'Expenses' },
@@ -939,61 +711,19 @@ export default function Invoices() {
{financeTab === 'expenses' && (
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
<button className="btn btn-outline" onClick={() => { setEditingExpenseId(''); setNewExpense(blankExpense()); setExpensesError(''); setShowExpenseForm(true); }}>+ Expense</button>
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }} ref={expYearMenuRef}>
<button className="btn btn-outline" onClick={() => setExpYearMenuOpen(o => !o)} style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }} title="Filter by year">
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M2 4h12M4 8h8M6 12h4" /></svg>
</button>
{expYearMenuOpen && (
<div className="site-header-avatar-menu" style={{ position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 220, minWidth: 130 }}>
<button onClick={() => { setExpYearFilter('all'); setExpYearMenuOpen(false); }} className="site-header-avatar-item" style={{ background: expYearFilter === 'all' ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent', color: expYearFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Years</button>
{expenseYears.map(y => (
<button key={y} onClick={() => { setExpYearFilter(y); setExpYearMenuOpen(false); }} className="site-header-avatar-item" style={{ background: expYearFilter === y ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent', color: expYearFilter === y ? 'var(--accent)' : 'var(--text-primary)' }}>{y}</button>
))}
</div>
)}
</div>
</div>
)}
{financeTab === 'invoices' && (
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
<button className="btn btn-outline" onClick={() => setShowInvoiceForm(true)}>+ Invoice</button>
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }} ref={invYearMenuRef}>
<button className="btn btn-outline" onClick={() => setInvYearMenuOpen(o => !o)} style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }} title="Filter by year">
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M2 4h12M4 8h8M6 12h4" /></svg>
</button>
{invYearMenuOpen && (
<div className="site-header-avatar-menu" style={{ position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 220, minWidth: 150 }}>
<div style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-muted)', padding: '6px 14px 4px' }}>Year</div>
<button onClick={() => setInvYearFilter('all')} className="site-header-avatar-item" style={{ background: invYearFilter === 'all' ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent', color: invYearFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Years</button>
{[...new Set(invoices.map(i => i.invoice_date?.slice(0,4)).filter(Boolean))].sort((a,b) => b-a).map(y => (
<button key={y} onClick={() => setInvYearFilter(y)} className="site-header-avatar-item" style={{ background: invYearFilter === y ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent', color: invYearFilter === y ? 'var(--accent)' : 'var(--text-primary)' }}>{y}</button>
))}
<div style={{ borderTop: '1px solid var(--border)', margin: '4px 0' }} />
<div style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-muted)', padding: '4px 14px 4px' }}>Company</div>
<button onClick={() => setInvCompanyFilter('all')} className="site-header-avatar-item" style={{ background: invCompanyFilter === 'all' ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent', color: invCompanyFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Companies</button>
{[...new Set(invoices.map(i => i.company?.name || i.bill_to).filter(Boolean))].sort().map(c => (
<button key={c} onClick={() => setInvCompanyFilter(c)} className="site-header-avatar-item" style={{ background: invCompanyFilter === c ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent', color: invCompanyFilter === c ? 'var(--accent)' : 'var(--text-primary)' }}>{c}</button>
))}
</div>
)}
</div>
</div>
)}
</div>
<div />
</div>
{financeTab === 'overview' && (() => {
const CARD = { background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' };
const TH = { fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.6, padding: '0 0 12px 0', border: 'none', background: 'transparent', textAlign: 'left' };
const TD = { fontSize: 13, color: 'var(--text-primary)', padding: '5px 0', border: 'none', background: 'transparent', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
const sortRows = (arr, key, dir, getter) => [...arr].sort((a, b) => {
const av = getter(a, key), bv = getter(b, key);
if (av < bv) return dir === 'asc' ? -1 : 1;
if (av > bv) return dir === 'asc' ? 1 : -1;
return 0;
});
const fmtDate = d => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '—';
const fmtAmt = v => `$${Number(v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
const now = new Date();
@@ -1071,9 +801,9 @@ export default function Invoices() {
);
return (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 24, flex: 1, minHeight: 0 }}>
{/* Card 1: This Month Expenses by category */}
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0, overflowY: 'auto' }}>
<div style={{ ...CARD, display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 0, flex: 1, minHeight: 0 }}>
{/* Section 1: This Month Expenses by category */}
<div style={{ display: 'flex', flexDirection: 'column', minHeight: 0, overflowY: 'auto', paddingRight: 21 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5, flexShrink: 0 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{MONTHS[m1]} {y1 !== curY ? y1 : ''} Expenses</span>
<MonthNav m={ovMonth1} setter={setOvMonth1} />
@@ -1099,8 +829,8 @@ export default function Invoices() {
{thisCatTotals.length > 0 && <PieLegend data={thisCatTotals.map(c => ({ name: c.cat, value: c.total }))} colors={PIE_COLORS} />}
</div>
{/* Card 2: Pending Sub Payments by subcontractor */}
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0, overflowY: 'auto' }}>
{/* Section 2: Pending Sub Payments by subcontractor */}
<div style={{ display: 'flex', flexDirection: 'column', minHeight: 0, overflowY: 'auto', borderLeft: '1px solid var(--border)', paddingLeft: 21, paddingRight: 21 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5, flexShrink: 0 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{MONTHS[m2]} {y2 !== curY ? y2 : ''} Sub Payments</span>
<MonthNav m={ovMonth2} setter={setOvMonth2} />
@@ -1126,8 +856,8 @@ export default function Invoices() {
{subPieData.length > 0 && <PieLegend data={subPieData} colors={PIE_COLORS} />}
</div>
{/* Card 3: Invoiced this month by company */}
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0, overflowY: 'auto' }}>
{/* Section 3: Invoiced this month by company */}
<div style={{ display: 'flex', flexDirection: 'column', minHeight: 0, overflowY: 'auto', borderLeft: '1px solid var(--border)', paddingLeft: 21 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5, flexShrink: 0 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{MONTHS[m3]} {y3 !== curY ? y3 : ''} Invoiced</span>
<MonthNav m={ovMonth3} setter={setOvMonth3} />
@@ -1171,11 +901,13 @@ export default function Invoices() {
.map(cat => ({ cat, total: filteredExpenses.filter(e => e.category === cat).reduce((s, e) => s + Number(e.amount || 0), 0) }))
.sort((a, b) => b.total - a.total);
const grandTotal = filteredExpenses.reduce((s, e) => s + Number(e.amount || 0), 0);
const yearOptions = [{ value: 'all', label: 'All Years' }, ...expenseYears.map(y => ({ value: y, label: y }))];
const categoryOptions = [{ value: 'all', label: 'All' }, ...CATEGORIES.map(c => ({ value: c, label: c }))];
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 0, flex: 1, minHeight: 0 }}>
<div style={{ display: 'grid', gridTemplateColumns: '7fr 3fr', gap: 24, flex: 1, minHeight: 0 }}>
<div style={{ ...CARD, display: 'grid', gridTemplateColumns: '7fr 3fr', gap: 0, flex: 1, minHeight: 0 }}>
{/* Left: expense list */}
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{ display: 'flex', flexDirection: 'column', minHeight: 0, paddingRight: 21 }}>
{expenses.length === 0 ? <div className="card-empty-center">No expenses</div> : (
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
@@ -1187,9 +919,25 @@ export default function Invoices() {
<col style={{ width: '12%' }} />
</colgroup>
<thead><tr>
<SortTh col="date" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={TH}>Date</SortTh>
<th style={{ ...TH, whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span onClick={() => expToggle('date')} style={{ cursor: 'pointer', userSelect: 'none' }}>
Date
<span style={{ marginLeft: 4, opacity: expSortKey === 'date' ? 0.85 : 0.2, fontSize: 9 }}>{expSortKey === 'date' ? (expSortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span>
</span>
<FilterDropdown value={expYearFilter} onChange={setExpYearFilter} options={yearOptions} />
</span>
</th>
<SortTh col="description" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={TH}>Description</SortTh>
<SortTh col="category" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={TH}>Category</SortTh>
<th style={{ ...TH, whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span onClick={() => expToggle('category')} style={{ cursor: 'pointer', userSelect: 'none' }}>
Category
<span style={{ marginLeft: 4, opacity: expSortKey === 'category' ? 0.85 : 0.2, fontSize: 9 }}>{expSortKey === 'category' ? (expSortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span>
</span>
<FilterDropdown value={expenseFilter} onChange={setExpenseFilter} options={categoryOptions} />
</span>
</th>
<SortTh col="notes" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={TH}>Notes</SortTh>
<SortTh col="amount" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={{ ...TH, textAlign: 'right' }}>Amount</SortTh>
</tr></thead>
@@ -1211,7 +959,7 @@ export default function Invoices() {
)}
</div>
{/* Right: category breakdown */}
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{ display: 'flex', flexDirection: 'column', minHeight: 0, borderLeft: '1px solid var(--border)', paddingLeft: 21 }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5, flexShrink: 0 }}>Category</div>
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: 'var(--danger)', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(grandTotal)}</div>
{categoryTotals.length === 0 ? <div className="card-empty-center">No expenses</div> : (
@@ -1249,7 +997,15 @@ export default function Invoices() {
const subInvoiceStatusColor = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
const invTotal = inv => (inv.items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
const yearFiltered = subInvoices;
const yearFiltered = subInvoices.filter(inv => {
if (subWhoFilter !== 'all' && (inv.profile?.name || 'Unknown') !== subWhoFilter) return false;
if (subStatusFilter !== 'all' && inv.status !== subStatusFilter) return false;
if (subYearFilter !== 'all' && (inv.submitted_at ? String(new Date(inv.submitted_at).getFullYear()) : '') !== subYearFilter) return false;
return true;
});
const subWhoOptions = [{ value: 'all', label: 'All' }, ...[...new Set(subInvoices.map(i => i.profile?.name || 'Unknown'))].sort().map(n => ({ value: n, label: n }))];
const subStatusOptions = [{ value: 'all', label: 'All' }, ...[...new Set(subInvoices.map(i => i.status).filter(Boolean))].sort().map(s => ({ value: s, label: s.charAt(0).toUpperCase() + s.slice(1) }))];
const subYearOptions = [{ value: 'all', label: 'All Years' }, ...[...new Set(subInvoices.map(i => i.submitted_at ? String(new Date(i.submitted_at).getFullYear()) : null).filter(Boolean))].sort((a,b) => b-a).map(y => ({ value: y, label: y }))];
const sortedInvoices = subSort(yearFiltered, (inv, key) => {
if (key === 'total') return invTotal(inv);
@@ -1275,9 +1031,9 @@ export default function Invoices() {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 0, flex: 1, minHeight: 0 }}>
<div style={{ display: 'grid', gridTemplateColumns: '7fr 3fr', gap: 24, flex: 1, minHeight: 0 }}>
<div style={{ ...CARD, display: 'grid', gridTemplateColumns: '7fr 3fr', gap: 0, flex: 1, minHeight: 0 }}>
{/* Left: invoice list */}
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{ display: 'flex', flexDirection: 'column', minHeight: 0, paddingRight: 21 }}>
{subInvoicesLoading ? (
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading</div>
) : subInvoicesError ? (
@@ -1297,10 +1053,34 @@ export default function Invoices() {
</colgroup>
<thead><tr>
<SortTh col="invoice_number" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={TH}>Invoice #</SortTh>
<SortTh col="subcontractor" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={TH}>Subcontractor</SortTh>
<SortTh col="submitted_at" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={TH}>Submitted</SortTh>
<th style={{ ...TH, whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span onClick={() => subToggle('subcontractor')} style={{ cursor: 'pointer', userSelect: 'none' }}>
Subcontractor
<span style={{ marginLeft: 4, opacity: subSortKey === 'subcontractor' ? 0.85 : 0.2, fontSize: 9 }}>{subSortKey === 'subcontractor' ? (subSortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span>
</span>
<FilterDropdown value={subWhoFilter} onChange={setSubWhoFilter} options={subWhoOptions} />
</span>
</th>
<th style={{ ...TH, whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span onClick={() => subToggle('submitted_at')} style={{ cursor: 'pointer', userSelect: 'none' }}>
Submitted
<span style={{ marginLeft: 4, opacity: subSortKey === 'submitted_at' ? 0.85 : 0.2, fontSize: 9 }}>{subSortKey === 'submitted_at' ? (subSortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span>
</span>
<FilterDropdown value={subYearFilter} onChange={setSubYearFilter} options={subYearOptions} />
</span>
</th>
<SortTh col="paid_at" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={TH}>Paid</SortTh>
<SortTh col="status" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={TH}>Status</SortTh>
<th style={{ ...TH, whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span onClick={() => subToggle('status')} style={{ cursor: 'pointer', userSelect: 'none' }}>
Status
<span style={{ marginLeft: 4, opacity: subSortKey === 'status' ? 0.85 : 0.2, fontSize: 9 }}>{subSortKey === 'status' ? (subSortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span>
</span>
<FilterDropdown value={subStatusFilter} onChange={setSubStatusFilter} options={subStatusOptions} />
</span>
</th>
<SortTh col="total" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={{ ...TH, textAlign: 'right' }}>Amount</SortTh>
</tr></thead>
<tbody>{sortedInvoices.map(inv => {
@@ -1332,7 +1112,7 @@ export default function Invoices() {
)}
</div>
{/* Right: by subcontractor */}
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{ display: 'flex', flexDirection: 'column', minHeight: 0, borderLeft: '1px solid var(--border)', paddingLeft: 21 }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5, flexShrink: 0 }}>Subcontractors</div>
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: 'var(--accent)', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(grandTotal)}</div>
{bySubcontractor.length === 0 ? <div className="card-empty-center">No data</div> : (
@@ -1395,11 +1175,13 @@ export default function Invoices() {
}, {})).sort((a, b) => b.total - a.total);
const grandTotal = byCompany.reduce((s, g) => s + g.total, 0);
const invYearOptions = [{ value: 'all', label: 'All Years' }, ...[...new Set(invoices.map(i => i.invoice_date?.slice(0,4)).filter(Boolean))].sort((a,b) => b-a).map(y => ({ value: y, label: y }))];
const invCompanyOptions = [{ value: 'all', label: 'All Companies' }, ...[...new Set(invoices.map(i => i.company?.name || i.bill_to).filter(Boolean))].sort().map(c => ({ value: c, label: c }))];
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 0, flex: 1, minHeight: 0 }}>
<div style={{ display: 'grid', gridTemplateColumns: '7fr 3fr', gap: 24, flex: 1, minHeight: 0 }}>
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{ ...CARD, display: 'grid', gridTemplateColumns: '7fr 3fr', gap: 0, flex: 1, minHeight: 0 }}>
<div style={{ display: 'flex', flexDirection: 'column', minHeight: 0, paddingRight: 21 }}>
{loading ? (
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading</div>
) : invoices.length === 0 ? (
@@ -1418,8 +1200,24 @@ export default function Invoices() {
</colgroup>
<thead><tr>
<SortTh col="invoice_number" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={TH}>Invoice #</SortTh>
<SortTh col="company" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={TH}>Company</SortTh>
<SortTh col="invoice_date" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={TH}>Date</SortTh>
<th style={{ ...TH, whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span onClick={() => invToggle('company')} style={{ cursor: 'pointer', userSelect: 'none' }}>
Company
<span style={{ marginLeft: 4, opacity: invSortKey === 'company' ? 0.85 : 0.2, fontSize: 9 }}>{invSortKey === 'company' ? (invSortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span>
</span>
<FilterDropdown value={invCompanyFilter} onChange={setInvCompanyFilter} options={invCompanyOptions} />
</span>
</th>
<th style={{ ...TH, whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span onClick={() => invToggle('invoice_date')} style={{ cursor: 'pointer', userSelect: 'none' }}>
Date
<span style={{ marginLeft: 4, opacity: invSortKey === 'invoice_date' ? 0.85 : 0.2, fontSize: 9 }}>{invSortKey === 'invoice_date' ? (invSortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span>
</span>
<FilterDropdown value={invYearFilter} onChange={setInvYearFilter} options={invYearOptions} />
</span>
</th>
<SortTh col="due_date" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={TH}>Due</SortTh>
<SortTh col="status" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={{ ...TH, textAlign: 'center' }}>Status</SortTh>
<SortTh col="stripe_fee" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={{ ...TH, textAlign: 'right' }}>Fees</SortTh>
@@ -1450,7 +1248,7 @@ export default function Invoices() {
</div>
)}
</div>
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{ display: 'flex', flexDirection: 'column', minHeight: 0, borderLeft: '1px solid var(--border)', paddingLeft: 21 }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5, flexShrink: 0 }}>Companies</div>
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: 'var(--positive)', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(grandTotal)}</div>
{byCompany.length === 0 ? <div className="card-empty-center">No data</div> : (
@@ -1490,7 +1288,7 @@ export default function Invoices() {
{ id: 'invoices', label: 'INVOICES' },
{ id: 'expenses', label: 'EXPENSES' },
{ id: 'sub-invoices', label: 'SUBCONTRACTOR INVOICES' },
].map((t, i, arr) => (
].map((t) => (
<button
key={t.id}
type="button"
@@ -1743,8 +1541,6 @@ export default function Invoices() {
title={invoice.invoice_number || 'Subcontractor Invoice'}
subtitle={`${invoice.profile?.name || 'External'}${invoice.profile?.email ? ` · ${invoice.profile.email}` : ''}`}
headerRight={<StatusBadge status={subInvoiceStatusColor[invoice.status] || 'not_started'} label={invoiceStatus} />}
onClose={() => { if (!markingPaid) setViewingSubInvoice(null); }}
blockClose={Boolean(markingPaid)}
metaContent={<>
<div><div style={F}>Submitted</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{invoice.submitted_at ? new Date(invoice.submitted_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div></div>
<div><div style={F}>Paid</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{invoice.paid_at ? new Date(invoice.paid_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div></div>
@@ -1801,7 +1597,7 @@ export default function Invoices() {
newExpense.removeReceipt === true
);
return (
<div style={popupOverlayStyle} onClick={() => { setViewingExpense(null); setExpenseDetailEditing(false); }}>
<div style={popupOverlayStyle}>
<div style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
{/* Main content row */}
<div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0 }}>
@@ -1896,7 +1692,7 @@ export default function Invoices() {
const LABEL = { ...FIELD_LABEL_STYLE, marginBottom: 3 };
const INPUT = FINANCE_MODAL_INPUT_STYLE;
return (
<div style={popupOverlayStyle} onClick={cancelExpenseEdit}>
<div style={popupOverlayStyle}>
<div style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
<form onSubmit={handleAddExpense} style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, gap: 0 }}>
{/* Main content row */}
@@ -1952,7 +1748,7 @@ export default function Invoices() {
const LABEL = { ...FIELD_LABEL_STYLE, marginBottom: 3 };
const INPUT = FINANCE_MODAL_INPUT_STYLE;
return (
<div style={popupOverlayStyle} onClick={() => { if (!invSaving) invClose(); }}>
<div style={popupOverlayStyle}>
<div style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0 }}>
+42 -7
View File
@@ -15,8 +15,12 @@ function fmtVersion(versionNumber) {
}
function billingStatusFromInvoices(items = []) {
if (!items.length) return 'not_started';
if (items.some((item) => item.invoice?.status === 'paid')) return 'paid';
// Only a sent or paid invoice counts as billed. Draft invoices and orphaned
// line items (invoice deleted invoice null) are treated as Not Invoiced,
// matching what the Finances page actually shows as issued.
const issued = items.filter((item) => item.invoice?.status === 'sent' || item.invoice?.status === 'paid');
if (!issued.length) return 'not_started';
if (issued.some((item) => item.invoice?.status === 'paid')) return 'paid';
return 'invoiced';
}
@@ -30,9 +34,16 @@ function subBillingStatusFromInvoices(items = []) {
function billingLabel(status) {
if (status === 'paid') return 'Paid';
if (status === 'invoiced') return 'Invoiced';
if (status === 'not_applicable') return 'N/A';
return 'Not Invoiced';
}
function asArray(value) {
if (Array.isArray(value)) return value;
if (value == null) return [];
return typeof value === 'object' ? [value] : [];
}
function versionTypeLabel(versionNumber) {
return Number(versionNumber || 0) === 0 ? 'New' : 'Revision';
}
@@ -81,6 +92,7 @@ export default function TeamReports() {
{ data: submissionRows, error: submissionsError },
{ data: invoiceItemRows, error: invoiceItemsError },
{ data: subInvoiceRows, error: subInvoicesError },
{ data: profileRows, error: profilesError },
] = await Promise.all([
supabase
.from('tasks')
@@ -88,7 +100,7 @@ export default function TeamReports() {
.order('title'),
supabase
.from('submissions')
.select('id, task_id, type, version_number, submitted_at, invoiced, description')
.select('id, task_id, type, version_number, submitted_at, invoiced, description, delivery:deliveries(sent_by)')
.in('type', ['initial', 'revision'])
.order('submitted_at', { ascending: true }),
supabase
@@ -97,16 +109,28 @@ export default function TeamReports() {
supabase
.from('subcontractor_invoices')
.select('status, items:subcontractor_invoice_items(task_id, version_number, description)')
.in('status', ['submitted', 'approved', 'paid'])
.in('status', ['submitted', 'approved', 'paid']),
supabase
.from('profiles')
.select('name, role'),
]);
if (tasksError) throw tasksError;
if (submissionsError) throw submissionsError;
if (invoiceItemsError) throw invoiceItemsError;
if (subInvoicesError) throw subInvoicesError;
if (profilesError) throw profilesError;
if (cancelled) return;
const taskMap = new Map((taskRows || []).map((task) => [task.id, task]));
// Sub billing only applies to work an external subcontractor delivered.
// A team member's own delivery is billed to the client directly and is
// never sub-invoiced, no matter who started the task's earlier versions.
const roleByName = new Map(
(profileRows || [])
.filter((profile) => profile.name)
.map((profile) => [profile.name.trim().toLowerCase(), profile.role])
);
const companiesMap = new Map();
const projectsMap = new Map();
(taskRows || []).forEach((task) => {
@@ -133,6 +157,12 @@ export default function TeamReports() {
if (!entry.first_submitted_at || (submission.submitted_at && submission.submitted_at < entry.first_submitted_at)) {
entry.first_submitted_at = submission.submitted_at;
}
// Duplicate submissions for the same task+version can exist (double-submits);
// take the deliverer from whichever one actually has a delivery logged.
if (!entry.delivered_by) {
const deliverer = asArray(submission.delivery)[0]?.sent_by;
if (deliverer) entry.delivered_by = deliverer;
}
}
const invoiceItemGroups = new Map();
@@ -186,7 +216,12 @@ export default function TeamReports() {
const key = `${task.id}:${Number(versionEntry.version_number || 0)}`;
const invoiceItems = invoiceItemGroups.get(key) || [];
const billingStatus = billingStatusFromInvoices(invoiceItems);
const subBillingStatus = subBillingStatusFromInvoices(subBillingGroups.get(key) || []);
const delivererRole = versionEntry.delivered_by
? roleByName.get(versionEntry.delivered_by.trim().toLowerCase()) || null
: null;
const subBillingStatus = delivererRole === 'external'
? subBillingStatusFromInvoices(subBillingGroups.get(key) || [])
: 'not_applicable';
rows.push({
key,
company_id: company?.id || '',
@@ -367,7 +402,7 @@ export default function TeamReports() {
) : sortedRows.length === 0 ? (
<div style={{ padding: 24, fontSize: 13, color: 'var(--text-muted)', textAlign: 'center' }}>No report rows for the selected filters.</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '15%' }} />
<col style={{ width: '15%' }} />
+2 -2
View File
@@ -159,7 +159,7 @@ export default function SubcontractorPODetail() {
<div className="detail-item"><label>Due Date</label><p>{po.due_date ? new Date(po.due_date).toLocaleDateString() : '—'}</p></div>
<div className="detail-item"><label>Terms</label><p>{po.terms || 'Net 15'}</p></div>
<div className="detail-item"><label>Project</label><p>{po.project?.name || 'No project'}</p></div>
<div className="detail-item"><label>Client</label><p>{po.project?.company?.name || '—'}</p></div>
<div className="detail-item"><label>Company</label><p>{po.project?.company?.name || '—'}</p></div>
<div className="detail-item"><label>Total</label><p style={{ fontSize: 18, fontWeight: 400, color: 'var(--accent)' }}>${Number(po.amount).toFixed(2)}</p></div>
{po.paid_at && <div className="detail-item"><label>Paid On</label><p>{new Date(po.paid_at).toLocaleDateString()}</p></div>}
</div>
@@ -169,7 +169,7 @@ export default function SubcontractorPODetail() {
<div className="card" style={{ marginBottom: 24 }}>
<div className="card-title">Line Items</div>
<div className="table-wrapper" style={{ border: 'none' }}>
<table>
<table className="table-sticky-head">
<thead>
<tr>
<SortTh col="project" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Project</SortTh>
@@ -0,0 +1,27 @@
-- Hourly reconcile of FileBrowser folders against companies/projects/tasks/subcontractors.
-- Vercel Hobby crons only run once per day, so the schedule lives in Postgres instead.
--
-- One-time manual step before this works (run once, do NOT commit the secret):
-- alter database postgres set app.folder_reconcile_secret = '<same value as FOLDER_RECONCILE_SECRET>';
create extension if not exists pg_cron with schema extensions;
create extension if not exists pg_net with schema extensions;
select cron.unschedule('folder-reconcile-hourly')
where exists (select 1 from cron.job where jobname = 'folder-reconcile-hourly');
select cron.schedule(
'folder-reconcile-hourly',
'7 * * * *',
$$
select net.http_post(
url := 'https://portal.fourgebranding.com/api/folder-reconcile',
headers := jsonb_build_object(
'Content-Type', 'application/json',
'x-reconcile-secret', current_setting('app.folder_reconcile_secret', true)
),
body := '{}'::jsonb,
timeout_milliseconds := 120000
);
$$
);
+1 -1
View File
@@ -4,7 +4,7 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: { port: 4173 },
server: { port: 5173 },
build: {
rollupOptions: {
output: {