Compare commits

...

6 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 16:04:24 -04:00
28 changed files with 574 additions and 532 deletions
+143
View File
@@ -0,0 +1,143 @@
import { createClient } from '@supabase/supabase-js';
export const FB_SOURCE = 'srv';
export const PROJECT_DEFAULT_SUBFOLDERS = ['00 Project Files'];
export const REQUEST_DEFAULT_SUBFOLDERS = ['Old Books', 'Working Files', 'Survey'];
export function normalizePath(path) {
const raw = String(path || '/').trim();
const parts = raw.split('/').filter(Boolean);
const clean = [];
for (const part of parts) {
if (part === '.') continue;
if (part === '..') throw new Error('Invalid path: path traversal not allowed');
clean.push(part);
}
return `/${clean.join('/')}`;
}
export function joinPath(...parts) {
return normalizePath(parts.join('/'));
}
export function safeName(value, fallback = '') {
const cleaned = String(value || '')
.trim()
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
.replace(/\s+/g, ' ')
.replace(/^-+|-+$/g, '');
return cleaned || fallback;
}
export function getConfig() {
const url = String(process.env.FILEBROWSER_URL || '').trim().replace(/\/+$/, '');
return {
url,
token: process.env.FILEBROWSER_TOKEN || '',
clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients'),
subsRoot: normalizePath(process.env.FILEBROWSER_SUBS_ROOT || '/fourgebranding/team'),
configured: Boolean(url),
};
}
export function getToken(config) {
const token = String(config.token || '').trim();
if (!token) throw new Error('FILEBROWSER_TOKEN not configured');
return token;
}
export async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) {
const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString();
const url = `${config.url}${endpoint}?${qs}`;
const token = getToken(config);
const response = await fetch(url, {
method,
headers: { Authorization: `Bearer ${token}`, ...headers },
body,
});
if (!response.ok) {
const text = await response.text().catch(() => '');
const error = new Error(text || `FileBrowser ${response.status}`);
error.status = response.status;
throw error;
}
const text = await response.text();
try {
return text ? JSON.parse(text) : null;
} catch {
return text;
}
}
function isAlreadyExistsError(error) {
const message = String(error?.message || '').toLowerCase();
return error?.status === 409 || message.includes('exist') || message.includes('conflict');
}
// Creates a single directory whose parent is already known to exist.
// Much cheaper than ensureDirectory, which re-walks the path from the root.
export async function createDirectory(config, fullPath) {
try {
await fbFetch(config, 'POST', '/api/resources', {
params: { path: normalizePath(fullPath), isDir: 'true' },
});
} catch (error) {
if (!isAlreadyExistsError(error)) throw error;
}
}
// Runs tasks with bounded concurrency, preserving input order in the result.
export async function pooled(items, limit, worker) {
const results = new Array(items.length);
let cursor = 0;
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (cursor < items.length) {
const index = cursor;
cursor += 1;
results[index] = await worker(items[index], index);
}
});
await Promise.all(runners);
return results;
}
export async function ensureDirectory(config, fullPath) {
const parts = normalizePath(fullPath).split('/').filter(Boolean);
let current = '/';
for (const part of parts) {
current = joinPath(current, part);
try {
await fbFetch(config, 'POST', '/api/resources', {
params: { path: current, isDir: 'true' },
});
} catch (error) {
if (!isAlreadyExistsError(error)) throw error;
}
}
}
// Returns directory names at `path`, or null when the path itself does not exist.
export async function listDirectories(config, path) {
let payload;
try {
payload = await fbFetch(config, 'GET', '/api/resources', { params: { path: normalizePath(path) } });
} catch (error) {
if (error?.status === 404) return null;
throw error;
}
// This FileBrowser returns child directories under `folders`; older builds use a mixed `items` array.
if (Array.isArray(payload?.folders)) return payload.folders.map(folder => folder.name);
const items = payload?.items || [];
return items.filter(item => item.type === 'directory' || item.isDir).map(item => item.name);
}
export function createAdminClient() {
const supabaseUrl = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !serviceKey) throw new Error('Supabase admin env not configured');
return createClient(supabaseUrl, serviceKey, {
auth: { persistSession: false, autoRefreshToken: false },
});
}
-12
View File
@@ -37,18 +37,6 @@ function safeName(value, fallback = '') {
return cleaned || fallback; 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 = [];
+225
View File
@@ -0,0 +1,225 @@
import {
PROJECT_DEFAULT_SUBFOLDERS,
REQUEST_DEFAULT_SUBFOLDERS,
createAdminClient,
createDirectory,
ensureDirectory,
getConfig,
joinPath,
listDirectories,
pooled,
safeName,
} from './_lib/filebrowser.js';
export const config = { maxDuration: 300 };
// Leave headroom under maxDuration so a partial run still returns its report.
const TIME_BUDGET_MS = 235000;
const CONCURRENCY = 8;
function json(res, status, body) {
res.status(status).setHeader('Content-Type', 'application/json');
res.setHeader('Cache-Control', 'no-store');
res.send(JSON.stringify(body));
}
function isAuthorized(req) {
const expected = String(process.env.FOLDER_RECONCILE_SECRET || '').trim();
if (!expected) return false;
const header = req.headers['x-reconcile-secret'];
const provided = String(Array.isArray(header) ? header[0] : header || '').trim();
if (provided.length !== expected.length) return false;
let diff = 0;
for (let i = 0; i < expected.length; i += 1) diff |= expected.charCodeAt(i) ^ provided.charCodeAt(i);
return diff === 0;
}
export default async function handler(req, res) {
if (req.method !== 'POST') return json(res, 405, { error: 'Method not allowed' });
if (!isAuthorized(req)) return json(res, 401, { error: 'Unauthorized' });
const fbConfig = getConfig();
if (!fbConfig.configured) {
return json(res, 200, { ok: false, configured: false, warning: 'FileBrowser is not configured.' });
}
const startedAt = Date.now();
const outOfTime = () => Date.now() - startedAt > TIME_BUDGET_MS;
const created = [];
const orphans = [];
const errors = [];
const record = (type, path) => created.push({ type, path });
const fail = (scope, error) => errors.push({ scope, message: String(error?.message || error).slice(0, 300) });
try {
const admin = createAdminClient();
const [companiesResult, projectsResult, tasksResult, subsResult] = await Promise.all([
admin.from('companies').select('id, name'),
admin.from('projects').select('id, name, company_id'),
admin.from('tasks').select('id, title, project_id'),
admin.from('profiles').select('id, name').eq('role', 'external'),
]);
for (const result of [companiesResult, projectsResult, tasksResult, subsResult]) {
if (result.error) throw result.error;
}
const companies = companiesResult.data || [];
const projects = projectsResult.data || [];
const tasks = tasksResult.data || [];
const subs = subsResult.data || [];
const rootDirs = await listDirectories(fbConfig, fbConfig.clientRoot);
if (rootDirs === null) {
await ensureDirectory(fbConfig, fbConfig.clientRoot);
record('client-root', fbConfig.clientRoot);
}
const rootSet = new Set(rootDirs || []);
let complete = true;
for (const company of companies) {
if (outOfTime()) { complete = false; break; }
const companyName = safeName(company.name, company.id);
const companyPath = joinPath(fbConfig.clientRoot, companyName);
const projectsPath = joinPath(companyPath, 'Projects');
const companyProjects = projects.filter(project => project.company_id === company.id);
try {
if (!rootSet.has(companyName)) {
await ensureDirectory(fbConfig, companyPath);
record('company', companyPath);
}
let projectDirs = await listDirectories(fbConfig, projectsPath);
if (projectDirs === null) {
if (companyProjects.length === 0) continue;
await ensureDirectory(fbConfig, projectsPath);
record('projects-root', projectsPath);
projectDirs = [];
}
const projectDirSet = new Set(projectDirs);
// Phase 1: create project folders that do not exist yet, with their defaults.
const missingProjects = companyProjects.filter(p => !projectDirSet.has(safeName(p.name, p.id)));
await pooled(missingProjects, CONCURRENCY, async (project) => {
const projectPath = joinPath(projectsPath, safeName(project.name, project.id));
try {
await createDirectory(fbConfig, projectPath);
record('project', projectPath);
for (const folderName of PROJECT_DEFAULT_SUBFOLDERS) {
await createDirectory(fbConfig, joinPath(projectPath, folderName));
}
} catch (error) {
fail(projectPath, error);
}
});
// Phase 2: list every project folder once to learn which task folders exist.
const existingProjects = companyProjects.filter(p => projectDirSet.has(safeName(p.name, p.id)));
const listings = await pooled(existingProjects, CONCURRENCY, async (project) => {
const projectPath = joinPath(projectsPath, safeName(project.name, project.id));
try {
return await listDirectories(fbConfig, projectPath);
} catch (error) {
fail(projectPath, error);
return null;
}
});
// Phase 3: create the missing leaves, flattened so the pool stays saturated.
const work = [];
existingProjects.forEach((project, index) => {
const childDirs = listings[index];
if (childDirs === null) return;
const childSet = new Set(childDirs);
const projectPath = joinPath(projectsPath, safeName(project.name, project.id));
for (const folderName of PROJECT_DEFAULT_SUBFOLDERS) {
if (!childSet.has(folderName)) work.push({ type: 'project-subfolder', path: joinPath(projectPath, folderName), children: [] });
}
for (const task of tasks.filter(t => t.project_id === project.id)) {
const taskName = safeName(task.title, task.id);
if (childSet.has(taskName)) continue;
work.push({ type: 'task', path: joinPath(projectPath, taskName), children: REQUEST_DEFAULT_SUBFOLDERS });
}
});
missingProjects.forEach((project) => {
const projectPath = joinPath(projectsPath, safeName(project.name, project.id));
for (const task of tasks.filter(t => t.project_id === project.id)) {
work.push({ type: 'task', path: joinPath(projectPath, safeName(task.title, task.id)), children: REQUEST_DEFAULT_SUBFOLDERS });
}
});
await pooled(work, CONCURRENCY, async (item) => {
if (outOfTime()) { complete = false; return; }
try {
await createDirectory(fbConfig, item.path);
record(item.type, item.path);
for (const folderName of item.children) {
await createDirectory(fbConfig, joinPath(item.path, folderName));
}
} catch (error) {
fail(item.path, error);
}
});
// Reported only — never deleted, since these may hold real work.
const expected = new Set(companyProjects.map(p => safeName(p.name, p.id)));
for (const dirName of projectDirSet) {
if (!expected.has(dirName)) orphans.push(joinPath(projectsPath, dirName));
}
} catch (error) {
fail(companyPath, error);
}
}
if (subs.length > 0 && !outOfTime()) {
try {
const subDirs = await listDirectories(fbConfig, fbConfig.subsRoot);
if (subDirs === null) {
await ensureDirectory(fbConfig, fbConfig.subsRoot);
record('subs-root', fbConfig.subsRoot);
}
const subDirSet = new Set(subDirs || []);
const missingSubs = subs.filter(profile => !subDirSet.has(safeName(profile.name, profile.id)));
await pooled(missingSubs, CONCURRENCY, async (profile) => {
const subPath = joinPath(fbConfig.subsRoot, safeName(profile.name, profile.id));
try {
await createDirectory(fbConfig, subPath);
record('subcontractor', subPath);
} catch (error) {
fail(subPath, error);
}
});
} catch (error) {
fail(fbConfig.subsRoot, error);
}
}
return json(res, 200, {
ok: errors.length === 0,
complete,
elapsedMs: Date.now() - startedAt,
configured: true,
createdCount: created.length,
created,
orphanCount: orphans.length,
orphans,
errorCount: errors.length,
errors: errors.slice(0, 50),
});
} catch (error) {
return json(res, error?.status || 500, {
ok: false,
complete: false,
elapsedMs: Date.now() - startedAt,
error: String(error?.message || 'Folder reconcile failed.'),
createdCount: created.length,
created,
errorCount: errors.length,
errors: errors.slice(0, 50),
});
}
}
-7
View File
@@ -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",
-1
View File
@@ -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
View File
@@ -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';
+1 -2
View File
@@ -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 -13
View File
@@ -1,22 +1,10 @@
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,
headerRight, headerRight,
onClose,
blockClose = false,
metaContent, // JSX fields rendered in flat meta grid (no cards) metaContent, // JSX fields rendered in flat meta grid (no cards)
metaActions, // optional JSX rendered right-aligned beside meta grid (e.g. Edit Dates) metaActions, // optional JSX rendered right-aligned beside meta grid (e.g. Edit Dates)
metaCols = 4, // number of grid columns for meta strip metaCols = 4, // number of grid columns for meta strip
@@ -25,7 +13,7 @@ export default function InvoiceDetailPopup({
children, children,
}) { }) {
return ( return (
<div style={popupOverlayStyle} onClick={() => { if (!blockClose) onClose?.(); }}> <div style={popupOverlayStyle}>
<div <div
style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }}
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
+1 -1
View File
@@ -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 = '',
+42 -45
View File
@@ -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%' }}
/> />
+36 -45
View File
@@ -5,10 +5,12 @@ import { supabase } from '../lib/supabase';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { sendEmail } from '../lib/email'; import { sendEmail } from '../lib/email';
import { isCompletedVersionEligible } from '../lib/invoiceVersionRules'; import { isCompletedVersionEligible } from '../lib/invoiceVersionRules';
import { isReviewShadowDescription } from '../lib/taskVersions';
import { useActionLock } from '../hooks/useActionLock'; import { useActionLock } from '../hooks/useActionLock';
const INVOICE_TODAY = new Date().toISOString().split('T')[0]; const INVOICE_TODAY = new Date().toISOString().split('T')[0];
const REVISION_RATE = 30; const REVISION_RATE = 30;
const ELIGIBLE_TASK_STATUSES = new Set(['not_started', 'in_progress', 'on_hold', 'client_review', 'client_approved', 'invoiced', 'paid']);
const FIELD_LABEL_STYLE = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, display: 'block', marginBottom: 4 }; const FIELD_LABEL_STYLE = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, display: 'block', marginBottom: 4 };
const FIELD_INPUT_STYLE = { minHeight: 42, margin: 0 }; const FIELD_INPUT_STYLE = { minHeight: 42, margin: 0 };
const FINANCE_MODAL_INPUT_STYLE = { const FINANCE_MODAL_INPUT_STYLE = {
@@ -113,20 +115,20 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o
setLoadingTasks(true); setLoadingTasks(true);
setError(''); setError('');
try { try {
const [{ data: tasks, error: tasksError }, { data: existingInvoices, error: invoicesError }, { data: nextNum, error: nextNumError }] = await Promise.all([ const [{ data: deliveryRows, error: deliveriesError }, { data: existingInvoices, error: invoicesError }, { data: nextNum, error: nextNumError }] = await Promise.all([
// Scoped by who actually delivered each version, not by current task assignment —
// a task can be reassigned mid-flight and the original sub still needs to bill
// for the version(s) they personally delivered.
supabase supabase
.from('tasks') .from('deliveries')
.select('id, title, status, current_version, project:projects(name)') .select('version_number, sent_by, submission:submissions!inner(task_id, description, task:tasks!inner(id, title, status, current_version, project:projects(name)))'),
.in('status', ['not_started', 'in_progress', 'on_hold', 'client_review', 'client_approved', 'invoiced', 'paid'])
.eq('assigned_to', currentUser.id)
.order('title'),
supabase supabase
.from('subcontractor_invoices') .from('subcontractor_invoices')
.select('id, status, items:subcontractor_invoice_items(task_id, version_number, description)') .select('id, status, items:subcontractor_invoice_items(task_id, version_number, description)')
.in('status', ['submitted', 'paid']), .in('status', ['submitted', 'paid']),
supabase.rpc('get_next_sub_invoice_number'), supabase.rpc('get_next_sub_invoice_number'),
]); ]);
if (tasksError) throw tasksError; if (deliveriesError) throw deliveriesError;
if (invoicesError) throw invoicesError; if (invoicesError) throw invoicesError;
if (nextNumError) throw nextNumError; if (nextNumError) throw nextNumError;
@@ -141,46 +143,31 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o
}).filter(Boolean)) }).filter(Boolean))
); );
const taskIds = (tasks || []).map((task) => task.id).filter(Boolean); const myName = (currentUser.name || '').trim().toLowerCase();
const { data: submissionRows, error: submissionsError } = taskIds.length > 0 const unitsByKey = new Map();
? await supabase for (const delivery of (deliveryRows || [])) {
.from('submissions') if ((delivery.sent_by || '').trim().toLowerCase() !== myName) continue;
.select('task_id, deliveries(version_number, sent_by, sent_at)') const submission = delivery.submission;
.in('task_id', taskIds) const task = submission?.task;
: { data: [], error: null }; if (!task || !ELIGIBLE_TASK_STATUSES.has(task.status)) continue;
if (submissionsError) throw submissionsError; if (isReviewShadowDescription(submission.description)) continue;
const version = Number(delivery.version_number || 0);
const deliveriesByTask = new Map(); if (!isCompletedVersionEligible(task, version)) continue;
for (const row of (submissionRows || [])) { const key = `${task.id}:${version}`;
const taskId = row.task_id; if (invoicedUnitKeys.has(key) || unitsByKey.has(key)) continue;
if (!taskId) continue; unitsByKey.set(key, {
const bucket = deliveriesByTask.get(taskId) || new Map(); key,
for (const delivery of asArray(row.deliveries)) { task_id: task.id,
if ((delivery.sent_by || '').trim().toLowerCase() !== (currentUser.name || '').trim().toLowerCase()) continue; version_number: version,
const version = Number(delivery.version_number || 0); is_revision: version > 0,
if (!bucket.has(version)) bucket.set(version, delivery); title: task.title,
} project_name: task.project?.name || '',
deliveriesByTask.set(taskId, bucket); description: `${task.project?.name ? `${task.project.name}` : ''}${task.title} ${versionLabel(version)}`,
rate: subcontractorVersionRate(version, rate),
});
} }
const units = (tasks || []).flatMap((task) => { const units = [...unitsByKey.values()].sort((a, b) => a.title.localeCompare(b.title) || a.version_number - b.version_number);
const versions = [...(deliveriesByTask.get(task.id)?.keys() || [])].sort((a, b) => a - b);
return versions
.filter((version) => isCompletedVersionEligible(task, version))
.map((version) => {
const key = `${task.id}:${version}`;
return {
key,
task_id: task.id,
version_number: version,
is_revision: version > 0,
title: task.title,
project_name: task.project?.name || '',
description: `${task.project?.name ? `${task.project.name}` : ''}${task.title} ${versionLabel(version)}`,
rate: subcontractorVersionRate(version, rate),
};
});
}).filter((unit) => !invoicedUnitKeys.has(unit.key));
setCompletedTasks(units); setCompletedTasks(units);
} catch (err) { } catch (err) {
@@ -360,6 +347,8 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o
</div> </div>
{loadingTasks ? ( {loadingTasks ? (
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>Loading</div> <div style={{ fontSize: 12, color: 'var(--text-muted)' }}>Loading</div>
) : error ? (
<div style={{ fontSize: 12, color: 'var(--danger)' }}>{error}</div>
) : completedTasks.length === 0 ? ( ) : completedTasks.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>No completed tasks available to invoice.</div> <div style={{ fontSize: 12, color: 'var(--text-muted)' }}>No completed tasks available to invoice.</div>
) : ( ) : (
@@ -455,6 +444,8 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o
</div> </div>
{loadingTasks ? ( {loadingTasks ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading...</p> <p style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading...</p>
) : error ? (
<p style={{ fontSize: 13, color: 'var(--danger)' }}>{error}</p>
) : completedTasks.length === 0 ? ( ) : completedTasks.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>No completed tasks available to invoice.</p> <p style={{ fontSize: 13, color: 'var(--text-muted)' }}>No completed tasks available to invoice.</p>
) : ( ) : (
+1 -8
View File
@@ -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';
}
+1 -1
View File
@@ -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;
} }
+10
View File
@@ -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)',
+8 -5
View File
@@ -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 }));
@@ -2353,7 +2358,6 @@ function DimensionEditorModal({ sourceImage, onApply, onCancel }) {
return ( return (
<div <div
style={{ ...popupOverlayStyle, zIndex: 9999, padding: 20 }} style={{ ...popupOverlayStyle, zIndex: 9999, padding: 20 }}
onClick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
> >
<div style={{ ...popupSurfaceStyle, borderRadius: 8, display: 'flex', flexDirection: 'column', maxWidth: '98vw', maxHeight: '96vh', overflow: 'hidden', padding: 0 }}> <div style={{ ...popupSurfaceStyle, borderRadius: 8, display: 'flex', flexDirection: 'column', maxWidth: '98vw', maxHeight: '96vh', overflow: 'hidden', padding: 0 }}>
<div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}> <div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
@@ -3436,7 +3440,6 @@ function PhotoEditorModal({
return ( return (
<div <div
style={{ ...popupOverlayStyle, zIndex: 9999, padding: 20 }} style={{ ...popupOverlayStyle, zIndex: 9999, padding: 20 }}
onClick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
> >
<div style={{ ...popupSurfaceStyle, borderRadius: 8, display: 'flex', flexDirection: 'column', maxWidth: '98vw', maxHeight: '96vh', overflow: 'hidden', padding: 0 }}> <div style={{ ...popupSurfaceStyle, borderRadius: 8, display: 'flex', flexDirection: 'column', maxWidth: '98vw', maxHeight: '96vh', overflow: 'hidden', padding: 0 }}>
{/* Header */} {/* Header */}
+3 -3
View File
@@ -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();
+1 -2
View File
@@ -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
+1 -2
View File
@@ -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);
@@ -642,7 +642,6 @@ export default function ProfilePage() {
{isSelfView && editOpen && ( {isSelfView && editOpen && (
<div <div
style={popupOverlayStyle} style={popupOverlayStyle}
onClick={() => { if (!savingProfile) setEditOpen(false); }}
> >
<div <div
style={{ style={{
+3 -22
View File
@@ -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));
@@ -596,7 +577,7 @@ export default function ProjectDetailPage() {
</div> </div>
{showClientTask && isClient && ( {showClientTask && isClient && (
<div style={popupOverlayStyle} onClick={() => { setShowClientTask(false); setClientTaskKey(k => k + 1); setClientTaskError(''); }}> <div style={popupOverlayStyle}>
<div style={TASK_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}> <div style={TASK_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}>
<div style={{ ...TASK_MODAL_TITLE_STYLE, marginBottom: 14 }}>New Task</div> <div style={{ ...TASK_MODAL_TITLE_STYLE, marginBottom: 14 }}>New Task</div>
<RequestForm <RequestForm
@@ -618,7 +599,7 @@ export default function ProjectDetailPage() {
)} )}
{addingMember && isTeam && ( {addingMember && isTeam && (
<div style={popupOverlayStyle} onClick={() => { setAddingMember(false); setSelectedExts(new Set()); }}> <div style={popupOverlayStyle}>
<div style={{ ...TASK_MODAL_SHELL_STYLE, width: 'min(480px, 96vw)', display: 'flex', flexDirection: 'column', gap: 16, padding: 0 }} onClick={e => e.stopPropagation()}> <div style={{ ...TASK_MODAL_SHELL_STYLE, width: 'min(480px, 96vw)', display: 'flex', flexDirection: 'column', gap: 16, padding: 0 }} onClick={e => e.stopPropagation()}>
<div style={{ padding: '18px 21px 0' }}> <div style={{ padding: '18px 21px 0' }}>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 2 }}>Manage Subcontractors</div> <div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 2 }}>Manage Subcontractors</div>
+7 -8
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom'; import { 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 });
@@ -1035,7 +1034,7 @@ export default function TaskDetail() {
</div> </div>
{reviewModal && ( {reviewModal && (
<div style={popupOverlayStyle} onClick={() => { if (!reviewSaving) { setReviewModal(false); setReviewFiles([]); setReviewNotes(''); } }}> <div style={popupOverlayStyle}>
<div style={{ ...popupSurfaceStyle, width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 14 }} onClick={e => e.stopPropagation()}> <div style={{ ...popupSurfaceStyle, width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 14 }} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }}>Place in Review</div> <div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }}>Place in Review</div>
<div> <div>
@@ -1054,7 +1053,7 @@ export default function TaskDetail() {
)} )}
{revisionModal && ( {revisionModal && (
<div style={{ position: 'fixed', inset: 0, background: 'var(--overlay-scrim, rgba(0,0,0,0.58))', zIndex: 1200, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }} onClick={() => { if (!revisionSaving) { setRevisionModal(false); setRevisionForm({ description: '', deadline: '' }); setRevisionFiles([]); } }}> <div style={{ position: 'fixed', inset: 0, background: 'var(--overlay-scrim, rgba(0,0,0,0.58))', zIndex: 1200, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}>
<div className="card" style={{ ...CARD, width: '100%', maxWidth: 480, display: 'flex', flexDirection: 'column', gap: 18 }} onClick={e => e.stopPropagation()}> <div className="card" style={{ ...CARD, width: '100%', maxWidth: 480, display: 'flex', flexDirection: 'column', gap: 18 }} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 18, fontWeight: 500, color: 'var(--text-primary)', letterSpacing: 0.2, lineHeight: 1.1 }}>Request Revision</div> <div style={{ fontSize: 18, fontWeight: 500, color: 'var(--text-primary)', letterSpacing: 0.2, lineHeight: 1.1 }}>Request Revision</div>
<div className="form-group"> <div className="form-group">
@@ -1090,7 +1089,7 @@ export default function TaskDetail() {
)} )}
{rejectModal && ( {rejectModal && (
<div style={popupOverlayStyle} onClick={() => { if (!rejectSaving) { setRejectModal(false); setRejectNote(''); } }}> <div style={popupOverlayStyle}>
<div style={{ ...popupSurfaceStyle, width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}> <div style={{ ...popupSurfaceStyle, width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', lineHeight: 1.1 }}>Reject Task</div> <div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', lineHeight: 1.1 }}>Reject Task</div>
<div> <div>
@@ -1114,7 +1113,7 @@ export default function TaskDetail() {
)} )}
{amendModal && ( {amendModal && (
<div style={popupOverlayStyle} onClick={() => { setAmendModal(false); setAmendForm({ description: '' }); setAmendFiles([]); }}> <div style={popupOverlayStyle}>
<div style={{ ...popupSurfaceStyle, width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}> <div style={{ ...popupSurfaceStyle, width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', lineHeight: 1.1 }}>Amend Request</div> <div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', lineHeight: 1.1 }}>Amend Request</div>
<div> <div>
+7 -66
View File
@@ -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,21 +588,13 @@ export default function RequestsPage() {
if (loadError) return <Layout><div style={{ padding: 24 }}><p style={{ color: 'var(--text-muted)', marginBottom: 12 }}>Failed to load. Check your connection and try again.</p><button className="btn btn-outline" onClick={() => window.location.reload()}>Retry</button></div></Layout>; 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} onClick={() => { setShowAddProjectForm(false); setAddProjectForm({ name: '', companyId: '' }); setAddProjectError(''); }}> <div style={popupOverlayStyle}>
<div style={PROJECT_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}> <div style={PROJECT_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 14 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 14 }}>
<div style={TASK_MODAL_TITLE_STYLE}>Add Project</div> <div style={TASK_MODAL_TITLE_STYLE}>Add Project</div>
@@ -682,7 +623,7 @@ export default function RequestsPage() {
{/* Add task modal */} {/* Add task modal */}
{showAddForm && ( {showAddForm && (
<div style={popupOverlayStyle} onClick={() => { setShowAddForm(false); setAddFormKey(k => k + 1); setAddError(''); }}> <div style={popupOverlayStyle}>
<div style={TASK_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}> <div style={TASK_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 14 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 14 }}>
<div style={TASK_MODAL_TITLE_STYLE}>{isTeam ? 'Add Task' : 'New Task'}</div> <div style={TASK_MODAL_TITLE_STYLE}>{isTeam ? 'Add Task' : 'New Task'}</div>
+3 -4
View File
@@ -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';
@@ -103,8 +105,6 @@ function ClientInvoiceModal({ invoice, onClose }) {
title={invoice.invoice_number} title={invoice.invoice_number}
subtitle={invoice.bill_to || company?.name} subtitle={invoice.bill_to || company?.name}
headerRight={<StatusBadge status={statusColor[invoice.status] || 'not_started'} label={`${invoiceStatusLabel(invoice.status)}${isOverdue ? ' · Overdue' : ''}`} />} headerRight={<StatusBadge status={statusColor[invoice.status] || 'not_started'} label={`${invoiceStatusLabel(invoice.status)}${isOverdue ? ' · Overdue' : ''}`} />}
onClose={onClose}
blockClose={Boolean(downloading)}
metaContent={<> metaContent={<>
<div><div style={F}>Invoice Date</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{invoice.invoice_date ? new Date(invoice.invoice_date).toLocaleDateString() : '—'}</div></div> <div><div style={F}>Invoice Date</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{invoice.invoice_date ? new Date(invoice.invoice_date).toLocaleDateString() : '—'}</div></div>
<div><div style={F}>Due Date</div><div style={{ fontSize: 13, color: isOverdue ? 'var(--danger)' : 'var(--text-primary)' }}>{invoice.due_date ? new Date(invoice.due_date).toLocaleDateString() : '—'}</div></div> <div><div style={F}>Due Date</div><div style={{ fontSize: 13, color: isOverdue ? 'var(--danger)' : 'var(--text-primary)' }}>{invoice.due_date ? new Date(invoice.due_date).toLocaleDateString() : '—'}</div></div>
@@ -167,7 +167,6 @@ export default function MyInvoices() {
return () => document.removeEventListener('mousedown', onDocClick); 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))
+3 -30
View File
@@ -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.');
@@ -428,7 +403,7 @@ export default function MyInvoices() {
</div> </div>
{showInvoiceForm && ( {showInvoiceForm && (
<div style={popupOverlayStyle} onClick={() => setShowInvoiceForm(false)}> <div style={popupOverlayStyle}>
<div <div
style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
@@ -469,8 +444,6 @@ export default function MyInvoices() {
title={inv?.invoice_number || 'Subcontractor Invoice'} title={inv?.invoice_number || 'Subcontractor Invoice'}
subtitle={`${currentUser?.name || 'Subcontractor'}${currentUser?.email ? ` · ${currentUser.email}` : ''}`} subtitle={`${currentUser?.name || 'Subcontractor'}${currentUser?.email ? ` · ${currentUser.email}` : ''}`}
headerRight={inv ? <StatusBadge status={STATUS_BADGE[inv.status] || 'not_started'} label={invoiceStatus} /> : null} headerRight={inv ? <StatusBadge status={STATUS_BADGE[inv.status] || 'not_started'} label={invoiceStatus} /> : null}
onClose={() => { if (!busy) { setViewingInvoice(null); setDetailError(''); } }}
blockClose={busy}
loading={detailLoading && !inv} loading={detailLoading && !inv}
metaContent={inv ? <> metaContent={inv ? <>
<div><div style={F}>Created</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{inv.created_at ? new Date(inv.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div></div> <div><div style={F}>Created</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{inv.created_at ? new Date(inv.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div></div>
+3 -16
View File
@@ -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>
); );
+2 -3
View File
@@ -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' };
@@ -403,8 +404,6 @@ export function TeamInvoiceDetailPanel({
label={`${invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—'}${isOverdue ? ' · Overdue' : ''}`} label={`${invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—'}${isOverdue ? ' · Overdue' : ''}`}
/> />
) : null} ) : null}
onClose={onClose}
blockClose={saving || Boolean(generating)}
loading={loading} loading={loading}
metaContent={invoice ? <> metaContent={invoice ? <>
<div> <div>
+9 -231
View File
@@ -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"
@@ -1761,8 +1541,6 @@ export default function Invoices() {
title={invoice.invoice_number || 'Subcontractor Invoice'} title={invoice.invoice_number || 'Subcontractor Invoice'}
subtitle={`${invoice.profile?.name || 'External'}${invoice.profile?.email ? ` · ${invoice.profile.email}` : ''}`} subtitle={`${invoice.profile?.name || 'External'}${invoice.profile?.email ? ` · ${invoice.profile.email}` : ''}`}
headerRight={<StatusBadge status={subInvoiceStatusColor[invoice.status] || 'not_started'} label={invoiceStatus} />} headerRight={<StatusBadge status={subInvoiceStatusColor[invoice.status] || 'not_started'} label={invoiceStatus} />}
onClose={() => { if (!markingPaid) setViewingSubInvoice(null); }}
blockClose={Boolean(markingPaid)}
metaContent={<> metaContent={<>
<div><div style={F}>Submitted</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{invoice.submitted_at ? new Date(invoice.submitted_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div></div> <div><div style={F}>Submitted</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{invoice.submitted_at ? new Date(invoice.submitted_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div></div>
<div><div style={F}>Paid</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{invoice.paid_at ? new Date(invoice.paid_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div></div> <div><div style={F}>Paid</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{invoice.paid_at ? new Date(invoice.paid_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div></div>
@@ -1819,7 +1597,7 @@ export default function Invoices() {
newExpense.removeReceipt === true newExpense.removeReceipt === true
); );
return ( return (
<div style={popupOverlayStyle} onClick={() => { setViewingExpense(null); setExpenseDetailEditing(false); }}> <div style={popupOverlayStyle}>
<div style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}> <div style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
{/* Main content row */} {/* Main content row */}
<div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0 }}> <div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0 }}>
@@ -1914,7 +1692,7 @@ export default function Invoices() {
const LABEL = { ...FIELD_LABEL_STYLE, marginBottom: 3 }; const LABEL = { ...FIELD_LABEL_STYLE, marginBottom: 3 };
const INPUT = FINANCE_MODAL_INPUT_STYLE; const INPUT = FINANCE_MODAL_INPUT_STYLE;
return ( return (
<div style={popupOverlayStyle} onClick={cancelExpenseEdit}> <div style={popupOverlayStyle}>
<div style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}> <div style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
<form onSubmit={handleAddExpense} style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, gap: 0 }}> <form onSubmit={handleAddExpense} style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, gap: 0 }}>
{/* Main content row */} {/* Main content row */}
@@ -1970,7 +1748,7 @@ export default function Invoices() {
const LABEL = { ...FIELD_LABEL_STYLE, marginBottom: 3 }; const LABEL = { ...FIELD_LABEL_STYLE, marginBottom: 3 };
const INPUT = FINANCE_MODAL_INPUT_STYLE; const INPUT = FINANCE_MODAL_INPUT_STYLE;
return ( return (
<div style={popupOverlayStyle} onClick={() => { if (!invSaving) invClose(); }}> <div style={popupOverlayStyle}>
<div style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}> <div style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0 }}> <div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0 }}>
+35 -4
View File
@@ -34,9 +34,16 @@ function subBillingStatusFromInvoices(items = []) {
function billingLabel(status) { function billingLabel(status) {
if (status === 'paid') return 'Paid'; if (status === 'paid') return 'Paid';
if (status === 'invoiced') return 'Invoiced'; if (status === 'invoiced') return 'Invoiced';
if (status === 'not_applicable') return 'N/A';
return 'Not Invoiced'; return 'Not Invoiced';
} }
function asArray(value) {
if (Array.isArray(value)) return value;
if (value == null) return [];
return typeof value === 'object' ? [value] : [];
}
function versionTypeLabel(versionNumber) { function versionTypeLabel(versionNumber) {
return Number(versionNumber || 0) === 0 ? 'New' : 'Revision'; return Number(versionNumber || 0) === 0 ? 'New' : 'Revision';
} }
@@ -85,6 +92,7 @@ export default function TeamReports() {
{ data: submissionRows, error: submissionsError }, { data: submissionRows, error: submissionsError },
{ data: invoiceItemRows, error: invoiceItemsError }, { data: invoiceItemRows, error: invoiceItemsError },
{ data: subInvoiceRows, error: subInvoicesError }, { data: subInvoiceRows, error: subInvoicesError },
{ data: profileRows, error: profilesError },
] = await Promise.all([ ] = await Promise.all([
supabase supabase
.from('tasks') .from('tasks')
@@ -92,7 +100,7 @@ export default function TeamReports() {
.order('title'), .order('title'),
supabase supabase
.from('submissions') .from('submissions')
.select('id, task_id, type, version_number, submitted_at, invoiced, description') .select('id, task_id, type, version_number, submitted_at, invoiced, description, delivery:deliveries(sent_by)')
.in('type', ['initial', 'revision']) .in('type', ['initial', 'revision'])
.order('submitted_at', { ascending: true }), .order('submitted_at', { ascending: true }),
supabase supabase
@@ -101,16 +109,28 @@ export default function TeamReports() {
supabase supabase
.from('subcontractor_invoices') .from('subcontractor_invoices')
.select('status, items:subcontractor_invoice_items(task_id, version_number, description)') .select('status, items:subcontractor_invoice_items(task_id, version_number, description)')
.in('status', ['submitted', 'approved', 'paid']) .in('status', ['submitted', 'approved', 'paid']),
supabase
.from('profiles')
.select('name, role'),
]); ]);
if (tasksError) throw tasksError; if (tasksError) throw tasksError;
if (submissionsError) throw submissionsError; if (submissionsError) throw submissionsError;
if (invoiceItemsError) throw invoiceItemsError; if (invoiceItemsError) throw invoiceItemsError;
if (subInvoicesError) throw subInvoicesError; if (subInvoicesError) throw subInvoicesError;
if (profilesError) throw profilesError;
if (cancelled) return; if (cancelled) return;
const taskMap = new Map((taskRows || []).map((task) => [task.id, task])); // Sub billing only applies to work an external subcontractor delivered.
// A team member's own delivery is billed to the client directly and is
// never sub-invoiced, no matter who started the task's earlier versions.
const roleByName = new Map(
(profileRows || [])
.filter((profile) => profile.name)
.map((profile) => [profile.name.trim().toLowerCase(), profile.role])
);
const companiesMap = new Map(); const companiesMap = new Map();
const projectsMap = new Map(); const projectsMap = new Map();
(taskRows || []).forEach((task) => { (taskRows || []).forEach((task) => {
@@ -137,6 +157,12 @@ export default function TeamReports() {
if (!entry.first_submitted_at || (submission.submitted_at && submission.submitted_at < entry.first_submitted_at)) { if (!entry.first_submitted_at || (submission.submitted_at && submission.submitted_at < entry.first_submitted_at)) {
entry.first_submitted_at = submission.submitted_at; entry.first_submitted_at = submission.submitted_at;
} }
// Duplicate submissions for the same task+version can exist (double-submits);
// take the deliverer from whichever one actually has a delivery logged.
if (!entry.delivered_by) {
const deliverer = asArray(submission.delivery)[0]?.sent_by;
if (deliverer) entry.delivered_by = deliverer;
}
} }
const invoiceItemGroups = new Map(); const invoiceItemGroups = new Map();
@@ -190,7 +216,12 @@ export default function TeamReports() {
const key = `${task.id}:${Number(versionEntry.version_number || 0)}`; const key = `${task.id}:${Number(versionEntry.version_number || 0)}`;
const invoiceItems = invoiceItemGroups.get(key) || []; const invoiceItems = invoiceItemGroups.get(key) || [];
const billingStatus = billingStatusFromInvoices(invoiceItems); const billingStatus = billingStatusFromInvoices(invoiceItems);
const subBillingStatus = subBillingStatusFromInvoices(subBillingGroups.get(key) || []); const delivererRole = versionEntry.delivered_by
? roleByName.get(versionEntry.delivered_by.trim().toLowerCase()) || null
: null;
const subBillingStatus = delivererRole === 'external'
? subBillingStatusFromInvoices(subBillingGroups.get(key) || [])
: 'not_applicable';
rows.push({ rows.push({
key, key,
company_id: company?.id || '', company_id: company?.id || '',
@@ -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
);
$$
);