Compare commits
3 Commits
1f52b6e96c
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 26ed7790b1 | |||
| 4c78b624f8 | |||
| 2cfbbec9dc |
@@ -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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -37,18 +37,6 @@ function safeName(value, fallback = '') {
|
|||||||
return cleaned || 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) {
|
async function readBody(req) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
|
|||||||
@@ -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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
-7
@@ -10,7 +10,6 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/supabase-js": "^2.99.3",
|
"@supabase/supabase-js": "^2.99.3",
|
||||||
"heic-to": "^1.4.2",
|
"heic-to": "^1.4.2",
|
||||||
"heic2any": "^0.0.4",
|
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
"jspdf-autotable": "^5.0.7",
|
"jspdf-autotable": "^5.0.7",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
@@ -2026,12 +2025,6 @@
|
|||||||
"integrity": "sha512-y69thwxfNcEm2Vk8lbOD/cMabnvMJyOREfJYiCHcXCDqlfcPyJoBhyRc8+iDe1B95LRfpbTOpzxzY1xbRkdwBA==",
|
"integrity": "sha512-y69thwxfNcEm2Vk8lbOD/cMabnvMJyOREfJYiCHcXCDqlfcPyJoBhyRc8+iDe1B95LRfpbTOpzxzY1xbRkdwBA==",
|
||||||
"license": "LGPL-3.0"
|
"license": "LGPL-3.0"
|
||||||
},
|
},
|
||||||
"node_modules/heic2any": {
|
|
||||||
"version": "0.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/heic2any/-/heic2any-0.0.4.tgz",
|
|
||||||
"integrity": "sha512-3lLnZiDELfabVH87htnRolZ2iehX9zwpRyGNz22GKXIu0fznlblf0/ftppXKNqS26dqFSeqfIBhAmAj/uSp0cA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/hermes-estree": {
|
"node_modules/hermes-estree": {
|
||||||
"version": "0.25.1",
|
"version": "0.25.1",
|
||||||
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
|
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/supabase-js": "^2.99.3",
|
"@supabase/supabase-js": "^2.99.3",
|
||||||
"heic-to": "^1.4.2",
|
"heic-to": "^1.4.2",
|
||||||
"heic2any": "^0.0.4",
|
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
"jspdf-autotable": "^5.0.7",
|
"jspdf-autotable": "^5.0.7",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import { lazy, Suspense, Component } from 'react';
|
import { lazy, Suspense, Component } from 'react';
|
||||||
import { BrowserRouter, Routes, Route, Navigate, useParams } from 'react-router-dom';
|
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 ProtectedRoute from './components/ProtectedRoute';
|
||||||
import PageLoader from './components/PageLoader';
|
import PageLoader from './components/PageLoader';
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ export default function FilterDropdown({ value, onChange, options }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
place();
|
|
||||||
function onDown(e) {
|
function onDown(e) {
|
||||||
if (btnRef.current?.contains(e.target) || menuRef.current?.contains(e.target)) return;
|
if (btnRef.current?.contains(e.target) || menuRef.current?.contains(e.target)) return;
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
@@ -48,7 +47,7 @@ export default function FilterDropdown({ value, onChange, options }) {
|
|||||||
<button
|
<button
|
||||||
ref={btnRef}
|
ref={btnRef}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen(o => !o)}
|
onClick={() => { if (!open) place(); setOpen(o => !o); }}
|
||||||
aria-label="Filter"
|
aria-label="Filter"
|
||||||
title="Filter"
|
title="Filter"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -1,16 +1,6 @@
|
|||||||
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
|
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
|
||||||
import PageLoader from './PageLoader';
|
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({
|
export default function InvoiceDetailPopup({
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ function normalizeName(value) {
|
|||||||
return String(value || '').trim().toLowerCase();
|
return String(value || '').trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveProfileAvatar({
|
function resolveProfileAvatar({
|
||||||
avatarUrl = '',
|
avatarUrl = '',
|
||||||
profile = null,
|
profile = null,
|
||||||
profileId = '',
|
profileId = '',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import { serviceTypes } from '../data/mockData';
|
import { serviceTypes } from '../data/mockData';
|
||||||
import FileAttachment from './FileAttachment';
|
import FileAttachment from './FileAttachment';
|
||||||
@@ -49,51 +49,23 @@ export default function RequestForm({
|
|||||||
requestedBy: showRequester ? (currentUser?.id || '') : '',
|
requestedBy: showRequester ? (currentUser?.id || '') : '',
|
||||||
...(initialValues || {}),
|
...(initialValues || {}),
|
||||||
}));
|
}));
|
||||||
const prevCompanyIdRef = useRef(initialValues?.companyId || initialCompanyId || null);
|
|
||||||
const [files, setFiles] = useState([]);
|
const [files, setFiles] = useState([]);
|
||||||
const [existingProjects, setExistingProjects] = useState([]);
|
const [existingProjects, setExistingProjects] = useState([]);
|
||||||
const [customProjects, setCustomProjects] = useState([]);
|
const [customProjects, setCustomProjects] = useState([]);
|
||||||
const [isTypingProject, setIsTypingProject] = useState(false);
|
const [isTypingProject, setIsTypingProject] = useState(false);
|
||||||
const [newProjectName, setNewProjectName] = useState('');
|
const [newProjectName, setNewProjectName] = useState('');
|
||||||
const [companyUsers, setCompanyUsers] = useState([]);
|
const [companyUsers, setCompanyUsers] = useState([]);
|
||||||
const [signFamilies, setSignFamilies] = useState([]);
|
|
||||||
const [signCount, setSignCount] = useState('');
|
const [signCount, setSignCount] = useState('');
|
||||||
const [signs, setSigns] = useState([]);
|
const [signs, setSigns] = useState([]);
|
||||||
const [localError, setLocalError] = useState('');
|
const [localError, setLocalError] = useState('');
|
||||||
|
|
||||||
const usesSignFields = form.serviceType === 'Brand Book';
|
const usesSignFields = form.serviceType === 'Brand Book';
|
||||||
|
|
||||||
useEffect(() => {
|
// Single-company user: lock selection to their one company (derived, not stored).
|
||||||
supabase.from('sign_families').select('name').order('sort_order').then(({ data }) => setSignFamilies((data || []).map(r => r.name)));
|
const companyId = form.companyId || (companies.length === 1 ? companies[0].id : '') || initialCompanyId;
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!usesSignFields) { setSignCount(''); setSigns([]); }
|
if (!companyId) return;
|
||||||
}, [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;
|
|
||||||
|
|
||||||
// Single-company user: lock selection to their one company.
|
|
||||||
useEffect(() => {
|
|
||||||
if (companies.length === 1 && !form.companyId) {
|
|
||||||
setForm(f => ({ ...f, companyId: companies[0].id }));
|
|
||||||
}
|
|
||||||
}, [companies, form.companyId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!companyId) { setExistingProjects([]); setCompanyUsers([]); return; }
|
|
||||||
Promise.all([
|
Promise.all([
|
||||||
supabase.from('projects').select('id, name').eq('company_id', companyId).order('name'),
|
supabase.from('projects').select('id, name').eq('company_id', companyId).order('name'),
|
||||||
showRequester
|
showRequester
|
||||||
@@ -117,13 +89,6 @@ export default function RequestForm({
|
|||||||
setCompanyUsers(merged);
|
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
|
}, [companyId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const set = (field) => (e) => {
|
const set = (field) => (e) => {
|
||||||
@@ -136,9 +101,41 @@ export default function RequestForm({
|
|||||||
setSigns(prev => prev.map(s => s.id === id ? { ...s, [field]: value } : s));
|
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 = [
|
const allProjectNames = [
|
||||||
...existingProjects.map(p => p.name),
|
...activeProjects.map(p => p.name),
|
||||||
...customProjects.filter(name => !existingProjects.some(p => p.name === name)),
|
...customProjects.filter(name => !activeProjects.some(p => p.name === name)),
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleProjectSelect = (e) => {
|
const handleProjectSelect = (e) => {
|
||||||
@@ -164,7 +161,7 @@ export default function RequestForm({
|
|||||||
const showCompanySelect = companies.length > 1 || showRequester;
|
const showCompanySelect = companies.length > 1 || showRequester;
|
||||||
const requesterOptions = [
|
const requesterOptions = [
|
||||||
...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)` }] : []),
|
...(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) => {
|
const handleSubmit = (e) => {
|
||||||
@@ -230,7 +227,7 @@ export default function RequestForm({
|
|||||||
) : showCompanySelect ? (
|
) : showCompanySelect ? (
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label style={modalLabelStyle}>Company *</label>
|
<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>
|
<option value="">Select company...</option>
|
||||||
{companies.map(co => <option key={co.id} value={co.id}>{co.name}</option>)}
|
{companies.map(co => <option key={co.id} value={co.id}>{co.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
@@ -265,7 +262,7 @@ export default function RequestForm({
|
|||||||
<div className="grid-2">
|
<div className="grid-2">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label style={modalLabelStyle}>Service Type *</label>
|
<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>
|
<option value="">Select service...</option>
|
||||||
{serviceTypes.map(s => <option key={s} value={s}>{s}</option>)}
|
{serviceTypes.map(s => <option key={s} value={s}>{s}</option>)}
|
||||||
</select>
|
</select>
|
||||||
@@ -315,7 +312,7 @@ export default function RequestForm({
|
|||||||
max="50"
|
max="50"
|
||||||
placeholder="How many signs?"
|
placeholder="How many signs?"
|
||||||
value={signCount}
|
value={signCount}
|
||||||
onChange={e => setSignCount(e.target.value)}
|
onChange={handleSignCountChange}
|
||||||
required
|
required
|
||||||
style={{ ...modalInputStyle, width: '100%' }}
|
style={{ ...modalInputStyle, width: '100%' }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
export function useLiveClock() {
|
function useLiveClock() {
|
||||||
const [now, setNow] = useState(() => new Date());
|
const [now, setNow] = useState(() => new Date());
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = setInterval(() => setNow(new Date()), 1000);
|
const id = setInterval(() => setNow(new Date()), 1000);
|
||||||
@@ -20,10 +20,3 @@ export function DashboardBanner() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getGreeting() {
|
|
||||||
const h = new Date().getHours();
|
|
||||||
if (h < 12) return 'Good morning';
|
|
||||||
if (h < 17) return 'Good afternoon';
|
|
||||||
return 'Good evening';
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -22,6 +22,6 @@ export function getRevisionChargeQuantity(versionNumber, revisionType) {
|
|||||||
// Parses version number from a subcontractor invoice item description like
|
// Parses version number from a subcontractor invoice item description like
|
||||||
// "Project • Task – R01". Legacy fallback only — new rows store version_number directly.
|
// "Project • Task – R01". Legacy fallback only — new rows store version_number directly.
|
||||||
export function parseVersionFromItemDescription(description = '') {
|
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;
|
return match ? Number(match[1]) : 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,16 @@ export const popupSurfaceStyle = {
|
|||||||
boxShadow: 'var(--popup-shadow)',
|
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 = {
|
export const popupMenuStyle = {
|
||||||
background: 'var(--popup-bg)',
|
background: 'var(--popup-bg)',
|
||||||
border: '1px solid var(--border)',
|
border: '1px solid var(--border)',
|
||||||
|
|||||||
@@ -159,7 +159,6 @@ export default function BrandBook() {
|
|||||||
const projectLogoRef = useRef();
|
const projectLogoRef = useRef();
|
||||||
const clientLogoRef = useRef();
|
const clientLogoRef = useRef();
|
||||||
const [filterCompany, setFilterCompany] = useState('');
|
const [filterCompany, setFilterCompany] = useState('');
|
||||||
const [selectedBookIds, setSelectedBookIds] = useState([]);
|
|
||||||
const { sortKey: bbSortKey, sortDir: bbSortDir, toggle: bbToggle, sort: bbSort } = useSortable('updated_at');
|
const { sortKey: bbSortKey, sortDir: bbSortDir, toggle: bbToggle, sort: bbSort } = useSortable('updated_at');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -175,7 +174,8 @@ export default function BrandBook() {
|
|||||||
|
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
if (address.includes(',') && address.length >= 12) {
|
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);
|
}, 900);
|
||||||
|
|
||||||
@@ -222,7 +222,6 @@ export default function BrandBook() {
|
|||||||
setLoadingBooks(true);
|
setLoadingBooks(true);
|
||||||
const { data } = await supabase.from('brand_books').select('*').order('updated_at', { ascending: false });
|
const { data } = await supabase.from('brand_books').select('*').order('updated_at', { ascending: false });
|
||||||
setSavedBooks(data || []);
|
setSavedBooks(data || []);
|
||||||
setSelectedBookIds(prev => prev.filter(id => (data || []).some(book => book.id === id)));
|
|
||||||
setLoadingBooks(false);
|
setLoadingBooks(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -235,6 +234,8 @@ export default function BrandBook() {
|
|||||||
setBookInfo(b => ({ ...b, revision: normalizeRevision(b.revision) }));
|
setBookInfo(b => ({ ...b, revision: normalizeRevision(b.revision) }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadMapsFromAddressRef = useRef(null);
|
||||||
|
|
||||||
const loadMapsFromAddress = async (address = bookInfo.customerAddress, { silent = false, feature = null } = {}) => {
|
const loadMapsFromAddress = async (address = bookInfo.customerAddress, { silent = false, feature = null } = {}) => {
|
||||||
const trimmed = String(address || '').trim();
|
const trimmed = String(address || '').trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
@@ -290,6 +291,10 @@ export default function BrandBook() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadMapsFromAddressRef.current = loadMapsFromAddress;
|
||||||
|
});
|
||||||
|
|
||||||
const handleCustomerAddressChange = (e) => {
|
const handleCustomerAddressChange = (e) => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
setBookInfo(b => ({ ...b, customerAddress: value, siteAddress: value }));
|
setBookInfo(b => ({ ...b, customerAddress: value, siteAddress: value }));
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ function TeamCompanies() {
|
|||||||
const [editingUserId, setEditingUserId] = useState(null);
|
const [editingUserId, setEditingUserId] = useState(null);
|
||||||
const [editUserVal, setEditUserVal] = useState('');
|
const [editUserVal, setEditUserVal] = useState('');
|
||||||
const [deletingUserId, setDeletingUserId] = useState(null);
|
const [deletingUserId, setDeletingUserId] = useState(null);
|
||||||
const [filterCompany, setFilterCompany] = useState('');
|
|
||||||
const [userSubTab, setUserSubTab] = useState(profileRole === 'external' ? 'external' : 'client');
|
const [userSubTab, setUserSubTab] = useState(profileRole === 'external' ? 'external' : 'client');
|
||||||
const { sortKey: coSortKey, sortDir: coSortDir, toggle: coToggle, sort: coSort } = useSortable('name');
|
const { sortKey: coSortKey, sortDir: coSortDir, toggle: coToggle, sort: coSort } = useSortable('name');
|
||||||
const { sortKey: clSortKey, sortDir: clSortDir, toggle: clToggle, sort: clSort } = useSortable('name');
|
const { sortKey: clSortKey, sortDir: clSortDir, toggle: clToggle, sort: clSort } = useSortable('name');
|
||||||
@@ -51,6 +50,9 @@ function TeamCompanies() {
|
|||||||
setLoading(false);
|
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(); }, []);
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
const handleCreate = async (e) => {
|
const handleCreate = async (e) => {
|
||||||
@@ -144,8 +146,6 @@ function TeamCompanies() {
|
|||||||
console.error('Subcontractor folder sync failed:', folderError);
|
console.error('Subcontractor folder sync failed:', folderError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (userForm.role === 'team' || userForm.role === 'external') {
|
|
||||||
}
|
|
||||||
setShowNewUser(false);
|
setShowNewUser(false);
|
||||||
setUserForm({ name: '', email: '', password: '', company_id: '', role: 'client' });
|
setUserForm({ name: '', email: '', password: '', company_id: '', role: 'client' });
|
||||||
load();
|
load();
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import StatusBadge from '../components/StatusBadge';
|
|||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { serviceTypes } from '../data/mockData';
|
import { serviceTypes } from '../data/mockData';
|
||||||
import { cleanupTaskStorage, deleteCompanyData } from '../lib/deleteHelpers';
|
import { deleteCompanyData } from '../lib/deleteHelpers';
|
||||||
import { logActivity } from '../lib/activityLog';
|
import { logActivity } from '../lib/activityLog';
|
||||||
import { ensureProjectFolder, renameCompanyFolder } from '../lib/folderSync';
|
import { ensureProjectFolder, renameCompanyFolder } from '../lib/folderSync';
|
||||||
|
|
||||||
@@ -70,7 +70,6 @@ export default function CompanyDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
load();
|
load();
|
||||||
}, [id]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ export default function ProfilePage() {
|
|||||||
setViewedProfile(profile);
|
setViewedProfile(profile);
|
||||||
setViewedCompanies([...names]);
|
setViewedCompanies([...names]);
|
||||||
setPrimaryCompanyAddress(primaryAddress);
|
setPrimaryCompanyAddress(primaryAddress);
|
||||||
} catch (err) {
|
} catch {
|
||||||
if (!cancelled) setProfileError('Unable to load profile.');
|
if (!cancelled) setProfileError('Unable to load profile.');
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) setLoadingProfile(false);
|
if (!cancelled) setLoadingProfile(false);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
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 Layout from '../components/Layout';
|
||||||
import PageLoader from '../components/PageLoader';
|
import PageLoader from '../components/PageLoader';
|
||||||
import ProfileAvatar from '../components/ProfileAvatar';
|
import ProfileAvatar from '../components/ProfileAvatar';
|
||||||
@@ -33,11 +33,9 @@ const MODAL_IN = { fontSize: 13, color: 'var(--text-primary)', background: 'va
|
|||||||
|
|
||||||
export default function ProjectDetailPage() {
|
export default function ProjectDetailPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const navigate = useNavigate();
|
|
||||||
const { currentUser } = useAuth();
|
const { currentUser } = useAuth();
|
||||||
|
|
||||||
const isClient = currentUser?.role === 'client';
|
const isClient = currentUser?.role === 'client';
|
||||||
const isExternal = currentUser?.role === 'external';
|
|
||||||
const isTeam = currentUser?.role === 'team';
|
const isTeam = currentUser?.role === 'team';
|
||||||
|
|
||||||
const { refreshKey } = useLiveRefresh(['tasks', 'projects', 'project_members', 'profiles']);
|
const { refreshKey } = useLiveRefresh(['tasks', 'projects', 'project_members', 'profiles']);
|
||||||
@@ -148,23 +146,6 @@ export default function ProjectDetailPage() {
|
|||||||
setSavingName(false);
|
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 () => {
|
const handleAddMember = async () => {
|
||||||
setSavingMembers(true);
|
setSavingMembers(true);
|
||||||
const currentIds = new Set(members.filter(m => m.profile?.role === 'external').map(m => m.profile_id));
|
const currentIds = new Set(members.filter(m => m.profile?.role === 'external').map(m => m.profile_id));
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useParams, Link } from 'react-router-dom';
|
import { useParams, Link } from 'react-router-dom';
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
import PageLoader from '../components/PageLoader';
|
import PageLoader from '../components/PageLoader';
|
||||||
@@ -13,7 +13,7 @@ import { useLiveRefresh } from '../hooks/useLiveRefresh';
|
|||||||
import { useActionLock } from '../hooks/useActionLock';
|
import { useActionLock } from '../hooks/useActionLock';
|
||||||
import FileAttachment from '../components/FileAttachment';
|
import FileAttachment from '../components/FileAttachment';
|
||||||
import { sendTaskStatusUpdate } from '../lib/taskNotifications';
|
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 { getCurrentVersionForTask } from '../lib/taskDeadlines';
|
||||||
import { getSubmissionDisplayName, mergeSubmissionDisplayNames } from '../lib/submissionDisplay';
|
import { getSubmissionDisplayName, mergeSubmissionDisplayNames } from '../lib/submissionDisplay';
|
||||||
|
|
||||||
@@ -261,7 +261,6 @@ export default function TaskDetail() {
|
|||||||
const [revisionForm, setRevisionForm] = useState({ description: '', deadline: '' });
|
const [revisionForm, setRevisionForm] = useState({ description: '', deadline: '' });
|
||||||
const [revisionFiles, setRevisionFiles] = useState([]);
|
const [revisionFiles, setRevisionFiles] = useState([]);
|
||||||
const [revisionSaving, setRevisionSaving] = useState(false);
|
const [revisionSaving, setRevisionSaving] = useState(false);
|
||||||
const revisionFileRef = useRef(null);
|
|
||||||
const [rejectModal, setRejectModal] = useState(false);
|
const [rejectModal, setRejectModal] = useState(false);
|
||||||
const [rejectNote, setRejectNote] = useState('');
|
const [rejectNote, setRejectNote] = useState('');
|
||||||
const [rejectSaving, setRejectSaving] = useState(false);
|
const [rejectSaving, setRejectSaving] = useState(false);
|
||||||
@@ -976,7 +975,7 @@ export default function TaskDetail() {
|
|||||||
</div>
|
</div>
|
||||||
{comments.length === 0
|
{comments.length === 0
|
||||||
? <div className="card-empty-center">No comments</div>
|
? <div className="card-empty-center">No comments</div>
|
||||||
: commentRows.map((c, i) => {
|
: commentRows.map((c) => {
|
||||||
const d = new Date(c.created_at);
|
const d = new Date(c.created_at);
|
||||||
const dateStr = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
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 });
|
const timeStr = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
|
||||||
|
|||||||
+5
-64
@@ -79,50 +79,7 @@ function TasksStatsRow({ tasks = [] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TasksPageShell({ statsTasks = [], projects = [], onAddProject = null, children }) {
|
function TasksPageShell({ statsTasks = [], 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;
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<div className="tasks-shell" 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} />
|
<TasksStatsRow tasks={statsTasks} />
|
||||||
@@ -268,7 +225,7 @@ export default function RequestsPage() {
|
|||||||
}
|
}
|
||||||
loadTasksPage();
|
loadTasksPage();
|
||||||
return () => { cancelled = true; };
|
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) => {
|
const handleAddRequest = async (formData, files, existingProjects) => {
|
||||||
if (addSaving) return;
|
if (addSaving) return;
|
||||||
@@ -293,7 +250,6 @@ export default function RequestsPage() {
|
|||||||
console.error('Request folder sync failed:', folderError);
|
console.error('Request folder sync failed:', folderError);
|
||||||
}
|
}
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
const taskCompany = companies?.find(c => c.id === formData.companyId);
|
|
||||||
setShowAddForm(false);
|
setShowAddForm(false);
|
||||||
setUploadProgress({ active: true, label: 'Uploading files…', percent: 0 });
|
setUploadProgress({ active: true, label: 'Uploading files…', percent: 0 });
|
||||||
for (let fi = 0; fi < files.length; fi++) {
|
for (let fi = 0; fi < files.length; fi++) {
|
||||||
@@ -459,7 +415,7 @@ export default function RequestsPage() {
|
|||||||
return cos.slice().sort((a, b) => a.name.localeCompare(b.name));
|
return cos.slice().sort((a, b) => a.name.localeCompare(b.name));
|
||||||
}
|
}
|
||||||
return companies.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
|
// Normalize all tasks to a common row shape for unified table render
|
||||||
const allRows = useMemo(() => {
|
const allRows = useMemo(() => {
|
||||||
@@ -487,7 +443,7 @@ export default function RequestsPage() {
|
|||||||
submittedAt: derived.latestActivityAt,
|
submittedAt: derived.latestActivityAt,
|
||||||
};
|
};
|
||||||
}).filter(Boolean);
|
}).filter(Boolean);
|
||||||
}, [tasks, submissions, deliveries, isClient]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [tasks, submissions, deliveries, isClient]);
|
||||||
|
|
||||||
const filteredRows = useMemo(() => {
|
const filteredRows = useMemo(() => {
|
||||||
return allRows
|
return allRows
|
||||||
@@ -587,13 +543,6 @@ export default function RequestsPage() {
|
|||||||
return '';
|
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 renderRow = (row) => {
|
||||||
const project = projects.find(p => p.id === row.projectId);
|
const project = projects.find(p => p.id === row.projectId);
|
||||||
@@ -639,18 +588,10 @@ 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>;
|
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 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 (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<TasksPageShell
|
<TasksPageShell statsTasks={filteredStatTasks}>
|
||||||
statsTasks={filteredStatTasks}
|
|
||||||
projects={filteredProjectsShell}
|
|
||||||
onAddProject={(isTeam || isClient) ? () => {
|
|
||||||
setAddProjectForm(f => ({ ...f, companyId: !isTeam && filterableCompanies.length === 1 ? filterableCompanies[0].id : f.companyId }));
|
|
||||||
setShowAddProjectForm(true); setAddProjectError('');
|
|
||||||
} : null}
|
|
||||||
>
|
|
||||||
{/* Add project modal */}
|
{/* Add project modal */}
|
||||||
{showAddProjectForm && (
|
{showAddProjectForm && (
|
||||||
<div style={popupOverlayStyle}>
|
<div style={popupOverlayStyle}>
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ import { supabase } from '../../lib/supabase';
|
|||||||
import { generateInvoicePDF, generateReceiptPDF } from '../../lib/invoice';
|
import { generateInvoicePDF, generateReceiptPDF } from '../../lib/invoice';
|
||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import { useSortable } from '../../hooks/useSortable';
|
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';
|
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 statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
|
||||||
const invoiceStatusLabel = (status) => {
|
const invoiceStatusLabel = (status) => {
|
||||||
if (status === 'sent') return 'Invoiced';
|
if (status === 'sent') return 'Invoiced';
|
||||||
@@ -165,7 +167,6 @@ export default function MyInvoices() {
|
|||||||
return () => document.removeEventListener('mousedown', onDocClick);
|
return () => document.removeEventListener('mousedown', onDocClick);
|
||||||
}, [filterMenuOpen]);
|
}, [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 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] : []))
|
const availableCompanyOptions = (currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : []))
|
||||||
.filter(company => company?.id && invoices.some(inv => inv.company_id === company.id))
|
.filter(company => company?.id && invoices.some(inv => inv.company_id === company.id))
|
||||||
|
|||||||
+2
-27
@@ -9,8 +9,8 @@ import { readPageCache, writePageCache } from '../../lib/pageCache';
|
|||||||
import { useSortable } from '../../hooks/useSortable';
|
import { useSortable } from '../../hooks/useSortable';
|
||||||
import { isCompletedVersionEligible } from '../../lib/invoiceVersionRules';
|
import { isCompletedVersionEligible } from '../../lib/invoiceVersionRules';
|
||||||
import SubcontractorInvoiceForm from '../../components/SubcontractorInvoiceForm';
|
import SubcontractorInvoiceForm from '../../components/SubcontractorInvoiceForm';
|
||||||
import { popupOverlayStyle, popupSurfaceStyle } from '../../lib/popupStyles';
|
import { popupOverlayStyle, popupSurfaceStyle, POPUP_FIELD_LABEL } from '../../lib/popupStyles';
|
||||||
import InvoiceDetailPopup, { POPUP_FIELD_LABEL } from '../../components/InvoiceDetailPopup';
|
import InvoiceDetailPopup from '../../components/InvoiceDetailPopup';
|
||||||
import InvoicePopupTable from '../../components/InvoicePopupTable';
|
import InvoicePopupTable from '../../components/InvoicePopupTable';
|
||||||
import { generateSubcontractorPOPDF } from '../../lib/invoice';
|
import { generateSubcontractorPOPDF } from '../../lib/invoice';
|
||||||
|
|
||||||
@@ -60,10 +60,6 @@ function parseItemVersionNumber(item) {
|
|||||||
return match ? Number(match[1]) : 0;
|
return match ? Number(match[1]) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function itemVersionLabel(item) {
|
|
||||||
return `R${String(parseItemVersionNumber(item)).padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function itemWorkLabel(item) {
|
function itemWorkLabel(item) {
|
||||||
return parseItemVersionNumber(item) > 0 ? 'Revision' : 'New';
|
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() || '—';
|
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) {
|
function buildPDFArgs(invoice, profile, total, statusOverride) {
|
||||||
return {
|
return {
|
||||||
po_number: invoice.invoice_number,
|
po_number: invoice.invoice_number,
|
||||||
@@ -125,7 +103,6 @@ export default function MyInvoices() {
|
|||||||
const [viewingInvoice, setViewingInvoice] = useState(null);
|
const [viewingInvoice, setViewingInvoice] = useState(null);
|
||||||
const [detailLoading, setDetailLoading] = useState(false);
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
const [detailError, setDetailError] = useState('');
|
const [detailError, setDetailError] = useState('');
|
||||||
const [detailTaskTypeMap, setDetailTaskTypeMap] = useState({});
|
|
||||||
const [submittingInvoice, setSubmittingInvoice] = useState(false);
|
const [submittingInvoice, setSubmittingInvoice] = useState(false);
|
||||||
const [downloadingInvoice, setDownloadingInvoice] = useState(false);
|
const [downloadingInvoice, setDownloadingInvoice] = useState(false);
|
||||||
const [downloadingReceipt, setDownloadingReceipt] = useState(false);
|
const [downloadingReceipt, setDownloadingReceipt] = useState(false);
|
||||||
@@ -249,8 +226,6 @@ export default function MyInvoices() {
|
|||||||
.eq('id', invoiceId)
|
.eq('id', invoiceId)
|
||||||
.single();
|
.single();
|
||||||
if (err || !data) throw err || new Error('Invoice not found.');
|
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);
|
setViewingInvoice(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setDetailError(err?.message || 'Failed to load invoice.');
|
setDetailError(err?.message || 'Failed to load invoice.');
|
||||||
|
|||||||
@@ -18,19 +18,6 @@ import { isReviewShadowDescription } from '../../lib/taskVersions';
|
|||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
// ─── 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) {
|
function fmtMoney(n) {
|
||||||
if (Math.abs(n) >= 1000000) return `$${(n / 1000000).toFixed(2)}M`;
|
if (Math.abs(n) >= 1000000) return `$${(n / 1000000).toFixed(2)}M`;
|
||||||
if (Math.abs(n) >= 1000) return `$${(n / 1000).toFixed(1)}k`;
|
if (Math.abs(n) >= 1000) return `$${(n / 1000).toFixed(1)}k`;
|
||||||
@@ -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 navigate = useNavigate();
|
||||||
const profileMap = useMemo(() => {
|
const profileMap = useMemo(() => {
|
||||||
const m = new Map();
|
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 });
|
items.push({ task: e.task, kind: e.version === 0 ? 'new' : 'rev', monthKey, person });
|
||||||
});
|
});
|
||||||
return items;
|
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 monthsWithData = useMemo(() => new Set(eligibleItems.map(i => i.monthKey).filter(Boolean)), [eligibleItems]);
|
||||||
const allMonthOpts = useMemo(() => {
|
const allMonthOpts = useMemo(() => {
|
||||||
@@ -869,7 +856,7 @@ export default function TeamDashboard() {
|
|||||||
<div className="dash-row-trio">
|
<div className="dash-row-trio">
|
||||||
<ActivityFeed events={activityEvents} />
|
<ActivityFeed events={activityEvents} />
|
||||||
<TasksInProgressCard tasks={inProgressTasks} />
|
<TasksInProgressCard tasks={inProgressTasks} />
|
||||||
<TeamPerformanceCard tasks={tasks} profiles={allProfiles} deliveries={perfDeliveries} submissions={perfSubmissions} />
|
<TeamPerformanceCard profiles={allProfiles} deliveries={perfDeliveries} submissions={perfSubmissions} />
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import { generateInvoicePDF, generateReceiptPDF } from '../../lib/invoice';
|
|||||||
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
|
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
|
||||||
import { withTimeout } from '../../lib/withTimeout';
|
import { withTimeout } from '../../lib/withTimeout';
|
||||||
import { useSortable } from '../../hooks/useSortable';
|
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';
|
import InvoicePopupTable from '../../components/InvoicePopupTable';
|
||||||
|
|
||||||
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
|
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
|
||||||
|
|||||||
@@ -11,36 +11,20 @@ import { useActionLock } from '../../hooks/useActionLock';
|
|||||||
import { supabase } from '../../lib/supabase';
|
import { supabase } from '../../lib/supabase';
|
||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
||||||
import { exportCPAPackage, generateSubcontractorPOPDF, generateInvoicePDF } from '../../lib/invoice';
|
import { generateSubcontractorPOPDF, generateInvoicePDF } from '../../lib/invoice';
|
||||||
import { withTimeout } from '../../lib/withTimeout';
|
import { withTimeout } from '../../lib/withTimeout';
|
||||||
import LoadingButton from '../../components/LoadingButton';
|
import LoadingButton from '../../components/LoadingButton';
|
||||||
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
|
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 FileAttachment from '../../components/FileAttachment';
|
||||||
import { isReviewShadowDescription, pickInitialServiceType } from '../../lib/taskVersions';
|
import { isReviewShadowDescription, pickInitialServiceType } from '../../lib/taskVersions';
|
||||||
import { getRevisionChargeQuantity, isCompletedVersionEligible, isInitialVersionEligible } from '../../lib/invoiceVersionRules';
|
import { getRevisionChargeQuantity, isCompletedVersionEligible, isInitialVersionEligible } from '../../lib/invoiceVersionRules';
|
||||||
import { TeamInvoiceDetailPanel } from './TeamInvoiceDetail';
|
import { TeamInvoiceDetailPanel } from './TeamInvoiceDetail';
|
||||||
import InvoiceDetailPopup, { POPUP_FIELD_LABEL } from '../../components/InvoiceDetailPopup';
|
import InvoiceDetailPopup from '../../components/InvoiceDetailPopup';
|
||||||
import InvoicePopupTable from '../../components/InvoicePopupTable';
|
import InvoicePopupTable from '../../components/InvoicePopupTable';
|
||||||
|
|
||||||
const CATEGORIES = ['Software', 'Contractor', 'Advertising', 'Office', 'Travel', 'Meals', 'Equipment', 'Other'];
|
const CATEGORIES = ['Software', 'Contractor', 'Advertising', 'Office', 'Travel', 'Meals', 'Equipment', 'Other'];
|
||||||
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
|
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
|
||||||
const poStatusColor = {
|
|
||||||
draft: 'not_started',
|
|
||||||
sent: 'in_progress',
|
|
||||||
approved: 'client_approved',
|
|
||||||
ready_to_pay: 'in_progress',
|
|
||||||
paid: 'client_approved',
|
|
||||||
cancelled: 'needs_revision',
|
|
||||||
};
|
|
||||||
const poStatusLabel = {
|
|
||||||
draft: 'Draft',
|
|
||||||
sent: 'Sent',
|
|
||||||
approved: 'Approved',
|
|
||||||
ready_to_pay: 'Ready to Pay',
|
|
||||||
paid: 'Paid',
|
|
||||||
cancelled: 'Cancelled',
|
|
||||||
};
|
|
||||||
const invoiceStatusBadgeLabel = (status) => {
|
const invoiceStatusBadgeLabel = (status) => {
|
||||||
if (status === 'sent') return 'Invoiced';
|
if (status === 'sent') return 'Invoiced';
|
||||||
return status ? status.charAt(0).toUpperCase() + status.slice(1) : '—';
|
return status ? status.charAt(0).toUpperCase() + status.slice(1) : '—';
|
||||||
@@ -126,10 +110,6 @@ function parseSubInvoiceItemVersionNumber(item) {
|
|||||||
return match ? Number(match[1]) : 0;
|
return match ? Number(match[1]) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function subInvoiceItemVersionLabel(item) {
|
|
||||||
return `R${String(parseSubInvoiceItemVersionNumber(item)).padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function subInvoiceItemWorkLabel(item) {
|
function subInvoiceItemWorkLabel(item) {
|
||||||
return parseSubInvoiceItemVersionNumber(item) > 0 ? 'Revision' : 'New';
|
return parseSubInvoiceItemVersionNumber(item) > 0 ? 'Revision' : 'New';
|
||||||
}
|
}
|
||||||
@@ -138,30 +118,6 @@ function cleanSubInvoiceItemDescription(item) {
|
|||||||
return String(item?.description || '—').replace(/\s*[–-]\s*R\d{2}\b/i, '').trim() || '—';
|
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' }) {
|
function CurrencyInput({ value, onChange, placeholder = '0.00', required = false, min = '0', step = '0.01' }) {
|
||||||
return (
|
return (
|
||||||
<div style={FINANCE_MODAL_AMOUNT_FIELD_STYLE}>
|
<div style={FINANCE_MODAL_AMOUNT_FIELD_STYLE}>
|
||||||
@@ -243,9 +199,6 @@ export default function Invoices() {
|
|||||||
const { sortKey: invSortKey, sortDir: invSortDir, toggle: invToggle, sort: invSort } = useSortable('invoice_date', 'desc');
|
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: expSortKey, sortDir: expSortDir, toggle: expToggle, sort: expSort } = useSortable('date', 'desc');
|
||||||
const { sortKey: subSortKey, sortDir: subSortDir, toggle: subToggle, sort: subSort } = useSortable('submitted_at');
|
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 { sortKey: subPopupKey, sortDir: subPopupDir, toggle: subPopupToggle, sort: subPopupSort } = useSortable('description');
|
||||||
const [filterCompany, setFilterCompany] = useState('');
|
const [filterCompany, setFilterCompany] = useState('');
|
||||||
const initMonth = () => { const n = new Date(); return { month: n.getMonth(), year: n.getFullYear() }; };
|
const initMonth = () => { const n = new Date(); return { month: n.getMonth(), year: n.getFullYear() }; };
|
||||||
@@ -253,7 +206,6 @@ export default function Invoices() {
|
|||||||
const [ovMonth2, setOvMonth2] = useState(initMonth);
|
const [ovMonth2, setOvMonth2] = useState(initMonth);
|
||||||
const [ovMonth3, setOvMonth3] = useState(initMonth);
|
const [ovMonth3, setOvMonth3] = useState(initMonth);
|
||||||
const [exportYear, setExportYear] = useState(new Date().getFullYear());
|
const [exportYear, setExportYear] = useState(new Date().getFullYear());
|
||||||
const [exporting, setExporting] = useState(false);
|
|
||||||
|
|
||||||
const [expenses, setExpenses] = useState([]);
|
const [expenses, setExpenses] = useState([]);
|
||||||
const [expensesLoading, setExpensesLoading] = useState(true);
|
const [expensesLoading, setExpensesLoading] = useState(true);
|
||||||
@@ -272,18 +224,12 @@ export default function Invoices() {
|
|||||||
const [subWhoFilter, setSubWhoFilter] = useState('all');
|
const [subWhoFilter, setSubWhoFilter] = useState('all');
|
||||||
const [subStatusFilter, setSubStatusFilter] = useState('all');
|
const [subStatusFilter, setSubStatusFilter] = useState('all');
|
||||||
const [subYearFilter, setSubYearFilter] = useState('all');
|
const [subYearFilter, setSubYearFilter] = useState('all');
|
||||||
const [subcontractorPOs, setSubcontractorPOs] = useState([]);
|
|
||||||
const [subcontractorLoading, setSubcontractorLoading] = useState(true);
|
|
||||||
const [subcontractorError, setSubcontractorError] = useState('');
|
|
||||||
const [selectedSubcontractorPOId, setSelectedSubcontractorPOId] = useState('');
|
|
||||||
const [subInvoices, setSubInvoices] = useState([]);
|
const [subInvoices, setSubInvoices] = useState([]);
|
||||||
const [subInvoicesLoading, setSubInvoicesLoading] = useState(true);
|
const [subInvoicesLoading, setSubInvoicesLoading] = useState(true);
|
||||||
const [subInvoicesError, setSubInvoicesError] = useState('');
|
const [subInvoicesError, setSubInvoicesError] = useState('');
|
||||||
const [markingPaid, setMarkingPaid] = useState('');
|
const [markingPaid, setMarkingPaid] = useState('');
|
||||||
const [viewingSubInvoice, setViewingSubInvoice] = useState(null);
|
const [viewingSubInvoice, setViewingSubInvoice] = useState(null);
|
||||||
const [viewingSubInvoiceTaskTypeMap, setViewingSubInvoiceTaskTypeMap] = useState({});
|
|
||||||
const [viewingInvoice, setViewingInvoice] = useState(null);
|
const [viewingInvoice, setViewingInvoice] = useState(null);
|
||||||
const [expandedSubInvoiceId, setExpandedSubInvoiceId] = useState(null);
|
|
||||||
|
|
||||||
// ── New Invoice form ──────────────────────────────────────────────────────
|
// ── New Invoice form ──────────────────────────────────────────────────────
|
||||||
const [showInvoiceForm, setShowInvoiceForm] = useState(false);
|
const [showInvoiceForm, setShowInvoiceForm] = useState(false);
|
||||||
@@ -301,30 +247,12 @@ export default function Invoices() {
|
|||||||
const guard = useActionLock();
|
const guard = useActionLock();
|
||||||
const [invLoadingTasks, setInvLoadingTasks] = useState(false);
|
const [invLoadingTasks, setInvLoadingTasks] = useState(false);
|
||||||
const invDragItem = useRef(null);
|
const invDragItem = useRef(null);
|
||||||
const pageLoading = loading || expensesLoading || subcontractorLoading || subInvoicesLoading;
|
const pageLoading = loading || expensesLoading || subInvoicesLoading;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
supabase.from('companies').select('id, name, contact_email').order('name').then(({ data }) => setInvCompanies(data || []));
|
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(() => {
|
useEffect(() => {
|
||||||
if (!invCompanyId) { setInvUnbilledTasks([]); setInvUnbilledRevisions([]); setInvPriceList([]); setInvItems([invNewItem()]); setInvBillTo(''); setInvEmail(''); setInvRecipients([]); return; }
|
if (!invCompanyId) { setInvUnbilledTasks([]); setInvUnbilledRevisions([]); setInvPriceList([]); setInvItems([invNewItem()]); setInvBillTo(''); setInvEmail(''); setInvRecipients([]); return; }
|
||||||
const co = invCompanies.find(c => c.id === invCompanyId);
|
const co = invCompanies.find(c => c.id === invCompanyId);
|
||||||
@@ -456,13 +384,7 @@ export default function Invoices() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadExpenses() {
|
async function loadExpenses() {
|
||||||
const [{ data, error }, { data: purchaseOrders, error: purchaseOrdersError }] = await Promise.all([
|
const { data, error } = await supabase.from('expenses').select('*').order('date', { ascending: false });
|
||||||
supabase.from('expenses').select('*').order('date', { ascending: false }),
|
|
||||||
supabase
|
|
||||||
.from('subcontractor_payments')
|
|
||||||
.select('*, profile:profiles!subcontractor_payments_profile_id_fkey(id, name, email), project:projects(id, name, company:companies(name)), items:subcontractor_po_items(*, task:tasks(id, title))')
|
|
||||||
.order('date', { ascending: false }),
|
|
||||||
]);
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Failed to load expenses:', error);
|
console.error('Failed to load expenses:', error);
|
||||||
setExpensesError(error.message || 'Failed to load expenses.');
|
setExpensesError(error.message || 'Failed to load expenses.');
|
||||||
@@ -471,16 +393,7 @@ export default function Invoices() {
|
|||||||
setExpenses(data || []);
|
setExpenses(data || []);
|
||||||
setExpensesError('');
|
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);
|
setExpensesLoading(false);
|
||||||
setSubcontractorLoading(false);
|
|
||||||
}
|
}
|
||||||
loadExpenses();
|
loadExpenses();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -579,37 +492,6 @@ export default function Invoices() {
|
|||||||
win.location.href = data.signedUrl;
|
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) => {
|
const handleMarkSubInvoicePaid = async (invoice) => {
|
||||||
setMarkingPaid(invoice.id);
|
setMarkingPaid(invoice.id);
|
||||||
try {
|
try {
|
||||||
@@ -710,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(
|
const paidYears = [...new Set(
|
||||||
invoices.filter(i => i.status === 'paid').map(i => new Date(i.invoice_date).getFullYear())
|
invoices.filter(i => i.status === 'paid').map(i => new Date(i.invoice_date).getFullYear())
|
||||||
)].sort((a, b) => b - a);
|
)].sort((a, b) => b - a);
|
||||||
@@ -783,45 +603,12 @@ export default function Invoices() {
|
|||||||
return true;
|
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 expenseYears = [...new Set(expenses.map(e => e.date?.slice(0, 4)).filter(Boolean))].sort((a, b) => b - a);
|
||||||
const filteredExpenses = expenses.filter(e => {
|
const filteredExpenses = expenses.filter(e => {
|
||||||
if (expYearFilter !== 'all' && e.date?.slice(0, 4) !== expYearFilter) return false;
|
if (expYearFilter !== 'all' && e.date?.slice(0, 4) !== expYearFilter) return false;
|
||||||
if (expenseFilter !== 'all' && e.category !== expenseFilter) return false;
|
if (expenseFilter !== 'all' && e.category !== expenseFilter) return false;
|
||||||
return true;
|
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 chartYear = exportYear;
|
||||||
const chartData = useMemo(() => MONTHS.map((month, mi) => {
|
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);
|
const paidInvs = invoices.filter(inv => inv.status === 'paid' && new Date(inv.invoice_date).getFullYear() === chartYear && new Date(inv.invoice_date).getMonth() === mi);
|
||||||
@@ -937,13 +724,6 @@ export default function Invoices() {
|
|||||||
const CARD = { background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' };
|
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 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 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 fmtAmt = v => `$${Number(v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -1508,7 +1288,7 @@ export default function Invoices() {
|
|||||||
{ id: 'invoices', label: 'INVOICES' },
|
{ id: 'invoices', label: 'INVOICES' },
|
||||||
{ id: 'expenses', label: 'EXPENSES' },
|
{ id: 'expenses', label: 'EXPENSES' },
|
||||||
{ id: 'sub-invoices', label: 'SUBCONTRACTOR INVOICES' },
|
{ id: 'sub-invoices', label: 'SUBCONTRACTOR INVOICES' },
|
||||||
].map((t, i, arr) => (
|
].map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t.id}
|
key={t.id}
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -131,7 +131,6 @@ export default function TeamReports() {
|
|||||||
.map((profile) => [profile.name.trim().toLowerCase(), profile.role])
|
.map((profile) => [profile.name.trim().toLowerCase(), profile.role])
|
||||||
);
|
);
|
||||||
|
|
||||||
const taskMap = new Map((taskRows || []).map((task) => [task.id, task]));
|
|
||||||
const companiesMap = new Map();
|
const companiesMap = new Map();
|
||||||
const projectsMap = new Map();
|
const projectsMap = new Map();
|
||||||
(taskRows || []).forEach((task) => {
|
(taskRows || []).forEach((task) => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
$$
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user