diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index f58066a..0000000 --- a/.claude/settings.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extraKnownMarketplaces": { - "ecc": { - "source": { - "source": "github", - "repo": "affaan-m/ecc" - } - } - }, - "enabledPlugins": { - "ecc@ecc": true - } -} diff --git a/.claude/settings.local.json b/.claude/settings.local.json index ab9566d..ef4cfcd 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,77 +1,16 @@ { "permissions": { "allow": [ - "Bash(*)", - "Bash(\"/Users/kraohasanee/Documents/40-49 Fourge:*)", - "Bash(vercel --version)", - "Bash(vercel --prod)", - "Bash(supabase --version)", - "Bash(brew install:*)", - "Read(//usr/local/bin/**)", - "Read(//Users/kraohasanee/.local/bin/**)", - "Read(//usr/**)", - "Bash(command -v supabase)", - "Read(//Users/kraohasanee/Library/**)", - "Bash(npx supabase:*)", - "Bash(echo $PATH)", - "Bash(supabase functions:*)", - "Bash(npm install:*)", - "Bash(export PATH=\"/opt/homebrew/bin:$PATH\")", - "Read(//opt/homebrew/bin/**)", - "Read(//Users/kraohasanee/.npm/bin/**)", - "Bash(export PATH=\"/usr/local/bin:/opt/homebrew/bin:$PATH\")", - "Read(//Users/kraohasanee/.supabase/**)", - "Bash(supabase status:*)", - "Bash(supabase orgs:*)", - "Bash(supabase projects:*)", - "Bash(supabase db:*)", - "Bash(npm run:*)", - "Bash(npx vercel:*)", - "Bash(curl -vI https://portal.fourgebranding.com)", - "Bash(dig portal.fourgebranding.com A +short)", - "Bash(dig portal.fourgebranding.com CNAME +short)", - "Bash(curl:*)", - "Bash(supabase migration:*)", - "Bash(ls \"/Users/kraohasanee/Documents/40-49 Fourge Branding/41 Website/fourge-portal\"/.env*)", - "Bash(supabase secrets:*)", - "Bash(stripe version:*)", - "Bash(stripe config:*)", - "Bash(stripe checkout:*)", - "Bash(stripe payment_intents list --limit 10)", - "Bash(stripe charges:*)", - "Bash(vercel ls:*)", - "Bash(vercel promote:*)", - "Bash(vercel inspect:*)", - "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(d.get\\('gitSource', d.get\\('meta', ''\\)\\)\\)\")", - "Bash(npx vite:*)", - "Bash(wait)", - "Bash(stripe webhook_endpoints list)", - "Bash(grep VITE_SUPABASE_ANON_KEY .env.local)", - "Bash(grep VITE_SUPABASE_ANON_KEY .env.production)", - "Bash(vercel whoami *)", - "Bash(vercel deploy *)", - "Bash(vercel build *)", - "Bash(vercel pull *)", - "Bash(sudo npm *)", - "Bash(npx vercel@latest --prod)", + "Bash(npm run *)", + "Bash(awk 'NR>=547 && /^}/ {print NR\": \"$0; c++; if\\(c>=1\\) exit}' src/pages/Companies.jsx)", + "Bash(sed -n '545,548p' src/pages/Companies.jsx)", + "Bash(sed -i '' '545,715d' src/pages/Companies.jsx)", + "Bash(sed -n '542,548p' src/pages/Companies.jsx)", + "Bash(sed -n '76,80p' src/pages/team/TeamSubInvoiceDetail.jsx)", + "Bash(sed -n '147p' src/pages/Profile.jsx)", + "Bash(awk '{print $5, $9}')", "Bash(git add *)", - "Bash(git commit -m ' *)", - "Bash(git push *)", - "Bash(cat .env.local)", - "Bash(cat .env)", - "Bash(vercel env *)", - "Bash(python3 -m json.tool)", - "Bash(xargs -0 '-I{}' bash -c 'name=$\\(basename \"{}\" .jsx\\); grep -q \"$name\" \"/Users/kraohasanee/Documents/40-49 Fourge Branding/41 Website/fourge-portal/src/App.jsx\" || echo \"UNLINKED: {}\"')", - "mcp__plugin_supabase_supabase__execute_sql", - "mcp__plugin_supabase_supabase__apply_migration", - "Bash(git commit *)", - "mcp__plugin_supabase_supabase__list_projects", - "WebFetch(domain:github.com)", - "Skill(supabase:supabase)", - "mcp__plugin_everything-claude-code_playwright__browser_navigate", - "mcp__plugin_everything-claude-code_exa__web_search_exa", - "WebFetch(domain:filebrowserquantum.com)", - "mcp__plugin_supabase_supabase__list_tables" + "Bash(git commit -q -m ' *)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 05205eb..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,126 +0,0 @@ -# Fourge Portal — Claude Instructions - -## Project -React + Vite SPA. Supabase backend. Deployed on Vercel. Local dev on port 5173 (or next available). Remote: `gitea` remote at `gitea.krao.us`. - -## Dev workflow -- Dev server: `npm run dev` (Vite, HMR active — no restart needed for code changes) -- Deploy: `vercel --prod` after every change, no need to ask -- Push: `git push gitea main` - -## Design system — follow layout.md as single source of truth - -### Card shell (all new widgets) -``` -background: var(--card-bg) -border: 1px solid var(--border) -border-radius: 8px -padding: 18px 21px -backdrop-filter: blur(12px) --webkit-backdrop-filter: blur(12px) -``` - -### Tokens -- Accent: `#F5A523` -- Card bg: `var(--card-bg)` = `rgba(255,255,255,0.02)` dark / `rgba(0,0,0,0.02)` light -- Secondary card: `var(--card-bg-2)` -- Border: `var(--border)` -- Text: `var(--text-primary)`, `var(--text-secondary)`, `var(--text-muted)` - -### Widget title (card section headers) -``` -font-size: 11px -font-weight: 500 -text-transform: uppercase -letter-spacing: 0.8px -color: var(--text-secondary) -margin-bottom: 14px -``` - -### Tables -- `table-layout: fixed`, `border-collapse: collapse` -- Headers: `font-size: 10px`, `font-weight: 500`, uppercase, `letter-spacing: 0.6px`, `padding-bottom: 12px`, `border: none`, `background: transparent` -- Use `SortTh` component for all sortable columns — every data column must be sortable -- Body cells: primary `13px`, secondary `12px`, `padding: 5px 0`, `border: none`, `background: transparent` -- Scrollable tables: container gets `className="scrollbar-thin-theme table-scroll-hidden"`, table gets `className="table-sticky-head"` -- Do NOT add `background: transparent` inline on TD — it blocks the `tr:hover td` CSS rule - -### Row hover -- Standard table rows: `tr:hover td { background: rgba(255,255,255,0.02) }` via global CSS — do not add inline bg to TD -- Clickable text inside rows: use `className="table-link"` — turns gold on hover, no full-row fill -- Do NOT use whole-row click + cursor pointer as the primary interaction pattern for new tables - -### Section control bar (tabs + actions) -``` -display: flex, align-items: center, gap: 4, margin-bottom: 10, flex-shrink: 0 -``` -- Tabs on the left -- Action buttons on the right via `margin-left: auto` — do NOT use hardcoded spacer divs for alignment -- Reference: Tasks.jsx tabs + `+ Task` button pattern - -### Buttons -- All buttons use `.btn` class with CSS vars (`--btn-height: 22px`, `--btn-radius: 8px`, etc.) -- Do not hardcode button geometry inline — use `btn btn-outline`, `btn btn-primary`, `btn btn-danger` -- Height controlled by `--h-control` = `--btn-height` - -### Modal forms -- Surface: `background: var(--card-bg)`, use `popupSurfaceStyle` + `popupOverlayStyle` from `../../lib/popupStyles` -- Field labels: `11px`, `500`, uppercase, `letter-spacing: 0.8px`, `color: var(--text-secondary)` -- Inputs: `font-size: 13px`, `background: var(--card-bg-2)`, `border: 1px solid var(--border)`, `border-radius: 6px`, `padding: 6px 10px` -- Form gap: `14px` between fields -- Action buttons: right-aligned, `btn btn-outline`, gap `8px` -- Header: single `11px` uppercase secondary label — no large heading - -### Status badges -- Use `` — do not hand-roll status tags -- Badge height: `20px`, `border-radius: 4px`, `font-size: 10px` - -### Spacing rhythm -- Page/grid/section gaps: `24px` -- Tabs-to-card gap: `margin-bottom: 10` on tabs bar - -### Height-filling layouts -For pages where cards should fill the window: -```jsx - -
- {/* fixed sections: flexShrink: 0 */} - {/* filling section: flex: 1, minHeight: 0 */} -
-
-``` -Cards that scroll internally: `display: flex, flexDirection: column, minHeight: 0` + inner `overflowY: auto, flex: 1, minHeight: 0` - -### File browser paths (per role) -- `team`: `/Clients/{Co}/Projects/{Proj}/{Task}` (real path) -- `client`: `/{Co}/Projects/{Proj}/{Task}` (virtual — no `/Clients/` prefix) -- `external`: `/Projects/{Proj}/{Task}` (virtual) - -## Empty states -Use `.card-empty-center` class for all "No …" messages inside cards. - -## Key files -- `layout.md` — visual spec, always check before building new UI -- `src/index.css` — global tokens and shared classes -- `src/components/SortTh.jsx` — sortable table header -- `src/components/StatusBadge.jsx` — status tags -- `src/lib/popupStyles.js` — modal overlay/surface styles -- `api/filebrowser.js` — file browser API (Vercel serverless) -- `supabase/functions/fbq-proxy/` — Supabase edge function for FileBrowser Quantum - -## Team Performance calculation - -Source: `deliveries` table (not `tasks`). - -Each row in `deliveries` = one unit of work by one person: -- `sent_by` — name of person who submitted the work -- `version_number` — 0 = new task delivery, 1+ = revision delivery -- `sent_at` — when submitted (bucketed by EST month) - -Rules: -- R00 → `newCount += 1` for `sent_by` -- R01, R02, R03… → `revCount += 1` each for `sent_by` -- Same task can have deliveries by different people — each counts independently -- Task status is **ignored** — a delivery record means work was done regardless of current task status (task goes back to `not_started` when revision is requested) -- Top 5 by total (new + revisions) shown per month -- Months shown only if delivery data exists for that month (EST timezone) diff --git a/api/archive-orphan-folders.js b/api/archive-orphan-folders.js deleted file mode 100644 index 9fe1e0d..0000000 --- a/api/archive-orphan-folders.js +++ /dev/null @@ -1,212 +0,0 @@ -import { createClient } from '@supabase/supabase-js'; - -const FB_SOURCE = 'srv'; - -function normalizePath(path) { - const raw = String(path || '/').trim(); - const parts = raw.split('/').filter(Boolean); - const clean = []; - for (const part of parts) { - if (part === '.') continue; - if (part === '..') throw new Error('Invalid path'); - clean.push(part); - } - return `/${clean.join('/')}`; -} - -function joinPath(...parts) { - return normalizePath(parts.join('/')); -} - -function safeName(value) { - return String(value || '') - .trim() - .replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-') - .replace(/\s+/g, ' ') - .replace(/^-+|-+$/g, ''); -} - -function basename(path) { - const parts = normalizePath(path).split('/').filter(Boolean); - return parts[parts.length - 1] || ''; -} - -function getFbConfig() { - const url = String(process.env.FILEBROWSER_URL || '').trim().replace(/\/+$/, ''); - return { - url, - token: process.env.FILEBROWSER_TOKEN || '', - teamRoot: normalizePath(process.env.FILEBROWSER_TEAM_ROOT || '/Team'), - clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/Clients'), - archiveRoot: normalizePath(process.env.FILEBROWSER_ARCHIVE_ROOT || '/Archive'), - configured: Boolean(url) && Boolean(process.env.FILEBROWSER_TOKEN), - }; -} - -async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) { - const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString(); - const res = await fetch(`${config.url}${endpoint}?${qs}`, { - method, - headers: { Authorization: `Bearer ${config.token}`, ...headers }, - body, - }); - if (!res.ok) { - const text = await res.text(); - const err = new Error(text || `FileBrowser ${res.status}`); - err.status = res.status; - throw err; - } - const text = await res.text(); - try { return text ? JSON.parse(text) : null; } catch { return text; } -} - -async function fbExists(config, path) { - try { await fbFetch(config, 'GET', '/api/resources', { params: { path } }); return true; } - catch (e) { if (e.status === 404) return false; throw e; } -} - -async function fbMkdir(config, path) { - await fbFetch(config, 'POST', '/api/resources', { params: { path, isDir: 'true' } }).catch(() => {}); -} - -async function listFolders(config, path) { - try { - const data = await fbFetch(config, 'GET', '/api/resources', { params: { path } }); - return (data?.folders || []).map(f => f.name).filter(Boolean); - } catch (e) { - if (e.status === 404) return []; - throw e; - } -} - -async function mergeMove(config, fbSrc, fbDstParent) { - const name = basename(fbSrc); - const fbDst = joinPath(fbDstParent, name); - const destExists = await fbExists(config, fbDst); - if (!destExists) { - await fbFetch(config, 'PATCH', '/api/resources', { - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - action: 'move', - items: [{ fromSource: FB_SOURCE, fromPath: fbSrc, toSource: FB_SOURCE, toPath: fbDst }], - overwrite: false, - }), - }); - return; - } - - const data = await fbFetch(config, 'GET', '/api/resources', { params: { path: fbSrc } }); - const dirs = (data?.folders || []).map(f => f.name); - const files = (data?.files || []).map(f => f.name); - await fbMkdir(config, fbDst); - for (const dir of dirs) await mergeMove(config, joinPath(fbSrc, dir), fbDst); - for (const file of files) { - await fbFetch(config, 'PATCH', '/api/resources', { - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - action: 'move', - items: [{ fromSource: FB_SOURCE, fromPath: joinPath(fbSrc, file), toSource: FB_SOURCE, toPath: joinPath(fbDst, file) }], - overwrite: true, - }), - }).catch(() => {}); - } - await fbFetch(config, 'DELETE', '/api/resources', { params: { path: fbSrc } }).catch(() => {}); -} - -function json(res, status, body) { - return res.status(status).json(body); -} - -export default async function handler(req, res) { - if (req.method !== 'POST') return json(res, 405, { error: 'Method not allowed' }); - - const secret = process.env.SUPABASE_WEBHOOK_SECRET; - if (secret) { - const incoming = req.headers['x-webhook-secret'] || ''; - if (incoming.trim() !== secret.trim()) return json(res, 401, { error: 'Unauthorized' }); - } - - const supabaseUrl = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL; - const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; - if (!supabaseUrl || !serviceRoleKey) return json(res, 500, { error: 'Supabase env not configured' }); - - const admin = createClient(supabaseUrl, serviceRoleKey, { - auth: { persistSession: false, autoRefreshToken: false }, - }); - - const config = getFbConfig(); - if (!config.configured) return json(res, 200, { ok: true, skipped: 'FileBrowser not configured' }); - - const results = { - archivedTeamFolders: [], - archivedProjectFolders: [], - errors: [], - }; - - await fbMkdir(config, config.archiveRoot); - await fbMkdir(config, joinPath(config.archiveRoot, 'Team')); - await fbMkdir(config, joinPath(config.archiveRoot, 'Clients')); - - const { data: profiles, error: profilesError } = await admin - .from('profiles') - .select('name, role') - .in('role', ['team', 'external']); - if (profilesError) return json(res, 500, { error: profilesError.message }); - - const validTeamFolders = new Set((profiles || []).map(profile => safeName(profile.name)).filter(Boolean)); - const teamFolders = await listFolders(config, config.teamRoot); - for (const folder of teamFolders) { - if (validTeamFolders.has(folder)) continue; - try { - await mergeMove(config, joinPath(config.teamRoot, folder), joinPath(config.archiveRoot, 'Team')); - results.archivedTeamFolders.push(folder); - } catch (e) { - results.errors.push(`team:${folder}: ${e.message}`); - } - } - - const { data: companies, error: companiesError } = await admin - .from('companies') - .select('id, name'); - if (companiesError) return json(res, 500, { error: companiesError.message }); - - const { data: projects, error: projectsError } = await admin - .from('projects') - .select('name, company_id'); - if (projectsError) return json(res, 500, { error: projectsError.message }); - - const projectsByCompany = new Map(); - for (const project of projects || []) { - if (!project?.company_id || !project?.name) continue; - const key = project.company_id; - if (!projectsByCompany.has(key)) projectsByCompany.set(key, new Set()); - projectsByCompany.get(key).add(safeName(project.name)); - } - - for (const company of companies || []) { - const companyFolder = safeName(company.name); - if (!companyFolder) continue; - const validProjects = projectsByCompany.get(company.id) || new Set(); - const projectsRoot = joinPath(config.clientRoot, companyFolder, 'Projects'); - const projectFolders = await listFolders(config, projectsRoot); - - await fbMkdir(config, joinPath(config.archiveRoot, 'Clients', companyFolder)); - await fbMkdir(config, joinPath(config.archiveRoot, 'Clients', companyFolder, 'Projects')); - - for (const folder of projectFolders) { - if (validProjects.has(folder)) continue; - try { - await mergeMove( - config, - joinPath(projectsRoot, folder), - joinPath(config.archiveRoot, 'Clients', companyFolder, 'Projects'), - ); - results.archivedProjectFolders.push(`${companyFolder}/${folder}`); - } catch (e) { - results.errors.push(`project:${companyFolder}/${folder}: ${e.message}`); - } - } - } - - return json(res, 200, { ok: true, ...results }); -} diff --git a/api/backfill-request-files.js b/api/backfill-request-files.js deleted file mode 100644 index 0a7404e..0000000 --- a/api/backfill-request-files.js +++ /dev/null @@ -1,222 +0,0 @@ -import { createClient } from '@supabase/supabase-js'; - -const FB_SOURCE = 'srv'; - -function normalizePath(path) { - const raw = String(path || '/').trim(); - const parts = raw.split('/').filter(Boolean); - const clean = []; - for (const part of parts) { - if (part === '.') continue; - if (part === '..') throw new Error('Invalid path'); - clean.push(part); - } - return `/${clean.join('/')}`; -} - -function joinPath(...parts) { - return normalizePath(parts.join('/')); -} - -function safeName(value) { - return String(value || '') - .trim() - .replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-') - .replace(/\s+/g, ' ') - .replace(/^-+|-+$/g, ''); -} - -function getConfig() { - const url = String(process.env.FILEBROWSER_URL || '').trim().replace(/\/+$/, ''); - return { - url, - token: process.env.FILEBROWSER_TOKEN || '', - clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/Clients'), - configured: Boolean(url), - }; -} - -async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) { - const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString(); - const res = await fetch(`${config.url}${endpoint}?${qs}`, { - method, - headers: { Authorization: `Bearer ${config.token}`, ...headers }, - body, - }); - if (!res.ok) { - const text = await res.text(); - throw new Error(text || `FileBrowser ${res.status}`); - } - return res; -} - -async function mkdir(config, path) { - await fbFetch(config, 'POST', '/api/resources', { - params: { path, isDir: 'true' }, - }).catch(() => {}); -} - -async function fbExists(config, path) { - try { - await fbFetch(config, 'GET', '/api/resources', { params: { path } }); - return true; - } catch (e) { - if (e.status === 404) return false; - throw e; - } -} - -async function ensureTaskStructure(config, companyName, projectName, taskTitle) { - const companyDir = joinPath(config.clientRoot, safeName(companyName)); - const projectsDir = joinPath(companyDir, 'Projects'); - const projectDir = joinPath(projectsDir, safeName(projectName)); - const taskDir = joinPath(projectDir, safeName(taskTitle)); - const requestInfoDir = joinPath(taskDir, 'Request Info'); - const workingFilesDir = joinPath(taskDir, 'Working Files'); - const oldBooksDir = joinPath(taskDir, 'Old Books'); - - await mkdir(config, companyDir); - await mkdir(config, projectsDir); - await mkdir(config, projectDir); - await mkdir(config, joinPath(projectDir, '00 Project Files')); - await mkdir(config, taskDir); - await mkdir(config, requestInfoDir); - await mkdir(config, workingFilesDir); - await mkdir(config, oldBooksDir); - - return { companyDir, projectDir, taskDir, requestInfoDir }; -} - -function json(res, status, body) { - return res.status(status).json(body); -} - -export default async function handler(req, res) { - if (req.method !== 'POST') return json(res, 405, { error: 'Method not allowed' }); - - const secret = process.env.SUPABASE_WEBHOOK_SECRET; - if (secret) { - const incoming = req.headers['x-webhook-secret'] || ''; - if (incoming.trim() !== secret.trim()) return json(res, 401, { error: 'Unauthorized' }); - } - - const supabaseUrl = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL; - const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; - if (!supabaseUrl || !serviceRoleKey) return json(res, 500, { error: 'Supabase env not configured' }); - - const admin = createClient(supabaseUrl, serviceRoleKey, { - auth: { persistSession: false, autoRefreshToken: false }, - }); - - const config = getConfig(); - if (!config.configured || !config.token) return json(res, 200, { ok: true, skipped: 'FileBrowser not configured' }); - - const { data: tasks, error: taskError } = await admin - .from('tasks') - .select(` - id, title, - project:projects!inner( - id, name, - company:companies!inner(name) - ) - `); - - if (taskError) return json(res, 500, { error: taskError.message }); - - const results = { - ensuredTasks: 0, - processed: 0, - skipped: 0, - errors: [], - }; - - for (const task of tasks || []) { - const project = task?.project; - const company = project?.company; - if (!task?.title || !project?.name || !company?.name) continue; - await ensureTaskStructure(config, company.name, project.name, task.title); - results.ensuredTasks++; - } - - // Fetch all submission files with task/project/company context - const { data: rows, error } = await admin - .from('submission_files') - .select(` - id, name, storage_path, size, - submission:submissions!inner( - id, version_number, - task:tasks!inner( - id, title, - project:projects!inner( - id, name, - company:companies!inner(name) - ) - ) - ) - `); - - if (error) return json(res, 500, { error: error.message }); - - // Group by task + version_number, skip groups with no files - const groups = new Map(); - for (const row of rows || []) { - const sub = row.submission; - const task = sub?.task; - const project = task?.project; - const company = project?.company; - if (!task || !project || !company) continue; - - const key = `${task.id}::${sub.version_number}`; - if (!groups.has(key)) { - groups.set(key, { - companyName: company.name, - projectName: project.name, - taskTitle: task.title, - versionNumber: sub.version_number, - files: [], - }); - } - groups.get(key).files.push({ name: row.name, storage_path: row.storage_path }); - } - for (const group of groups.values()) { - if (group.files.length === 0) { results.skipped++; continue; } - - const revFolder = `R${String(group.versionNumber).padStart(2, '0')}`; - const { requestInfoDir } = await ensureTaskStructure(config, group.companyName, group.projectName, group.taskTitle); - const revDir = joinPath(requestInfoDir, revFolder); - - await mkdir(config, revDir); - - for (const file of group.files) { - try { - const { data: blob, error: downloadError } = await admin.storage - .from('submissions') - .download(file.storage_path); - if (downloadError || !blob) { - results.errors.push(`download failed: ${file.storage_path}`); - continue; - } - const fileBuffer = await blob.arrayBuffer(); - const form = new FormData(); - form.append('file', new Blob([fileBuffer]), file.name); - - // Upload to FileBrowser - const fbFilePath = joinPath(revDir, file.name); - if (await fbExists(config, fbFilePath)) { - results.skipped++; - continue; - } - await fbFetch(config, 'POST', '/api/resources', { - params: { path: fbFilePath }, - body: form, - }); - - results.processed++; - } catch (err) { - results.errors.push(`${file.name}: ${err.message}`); - } - } - } - - return json(res, 200, { ok: true, ...results }); -} diff --git a/api/delete-project.js b/api/delete-project.js index 72f9332..0ee941d 100644 --- a/api/delete-project.js +++ b/api/delete-project.js @@ -1,130 +1,5 @@ import { createClient } from '@supabase/supabase-js'; -const FB_SOURCE = 'srv'; - -function normalizePath(path) { - const raw = String(path || '/').trim(); - const parts = raw.split('/').filter(Boolean); - const clean = []; - for (const part of parts) { - if (part === '.') continue; - if (part === '..') throw new Error('Invalid path'); - clean.push(part); - } - return `/${clean.join('/')}`; -} - -function joinPath(...parts) { - return normalizePath(parts.join('/')); -} - -function safeName(value) { - return String(value || '') - .trim() - .replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-') - .replace(/\s+/g, ' ') - .replace(/^-+|-+$/g, ''); -} - -function basename(path) { - const parts = normalizePath(path).split('/').filter(Boolean); - return parts[parts.length - 1] || ''; -} - -function parentDir(path) { - const parts = normalizePath(path).split('/').filter(Boolean); - parts.pop(); - return `/${parts.join('/')}`; -} - -function getFbConfig() { - const url = String(process.env.FILEBROWSER_URL || '').trim().replace(/\/+$/, ''); - return { - url, - token: process.env.FILEBROWSER_TOKEN || '', - clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/Clients'), - archiveRoot: normalizePath(process.env.FILEBROWSER_ARCHIVE_ROOT || '/Archive'), - configured: Boolean(url) && Boolean(process.env.FILEBROWSER_TOKEN), - }; -} - -async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) { - const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString(); - const res = await fetch(`${config.url}${endpoint}?${qs}`, { - method, - headers: { Authorization: `Bearer ${config.token}`, ...headers }, - body, - }); - if (!res.ok) { - const text = await res.text(); - const err = new Error(text || `FileBrowser ${res.status}`); - err.status = res.status; - throw err; - } - const text = await res.text(); - try { return text ? JSON.parse(text) : null; } catch { return text; } -} - -async function fbExists(config, path) { - try { await fbFetch(config, 'GET', '/api/resources', { params: { path } }); return true; } - catch (e) { if (e.status === 404) return false; throw e; } -} - -async function fbMkdir(config, path) { - await fbFetch(config, 'POST', '/api/resources', { params: { path, isDir: 'true' } }).catch(() => {}); -} - -async function mergeMove(config, fbSrc, fbDstParent) { - const name = basename(fbSrc); - const fbDst = joinPath(fbDstParent, name); - const destExists = await fbExists(config, fbDst); - if (!destExists) { - await fbFetch(config, 'PATCH', '/api/resources', { - params: {}, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - action: 'move', - items: [{ fromSource: FB_SOURCE, fromPath: fbSrc, toSource: FB_SOURCE, toPath: fbDst }], - overwrite: false, - }), - }); - } else { - const data = await fbFetch(config, 'GET', '/api/resources', { params: { path: fbSrc } }); - const dirs = (data?.folders || []).map(f => f.name); - const files = (data?.files || []).map(f => f.name); - await fbMkdir(config, fbDst); - for (const dir of dirs) await mergeMove(config, joinPath(fbSrc, dir), fbDst); - for (const file of files) { - await fbFetch(config, 'PATCH', '/api/resources', { - params: {}, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - action: 'move', - items: [{ fromSource: FB_SOURCE, fromPath: joinPath(fbSrc, file), toSource: FB_SOURCE, toPath: joinPath(fbDst, file) }], - overwrite: true, - }), - }).catch(() => {}); - } - await fbFetch(config, 'DELETE', '/api/resources', { params: { path: fbSrc } }).catch(() => {}); - } -} - -async function archiveProject(companyName, projectName) { - const config = getFbConfig(); - if (!config.configured || !companyName || !projectName) return; - const co = safeName(companyName); - const proj = safeName(projectName); - // Ensure archive dirs exist - await fbMkdir(config, config.archiveRoot); - await fbMkdir(config, joinPath(config.archiveRoot, 'Clients')); - await fbMkdir(config, joinPath(config.archiveRoot, 'Clients', co)); - await fbMkdir(config, joinPath(config.archiveRoot, 'Clients', co, 'Projects')); - // Merge-move project folder into archive - const srcPath = joinPath(config.clientRoot, co, 'Projects', proj); - const dstParentPath = joinPath(config.archiveRoot, 'Clients', co, 'Projects'); - await mergeMove(config, srcPath, dstParentPath); -} - export default async function handler(req, res) { if (req.method !== 'DELETE') return res.status(405).json({ error: 'Method not allowed' }); @@ -152,22 +27,11 @@ export default async function handler(req, res) { auth: { persistSession: false, autoRefreshToken: false }, }); - // Fetch project + company name before deletion (needed for archive path) - const { data: projRecord } = await admin - .from('projects') - .select('id, name, company:companies(name)') - .eq('id', projectId) - .single(); - - if (!projRecord) return res.status(404).json({ error: 'Project not found' }); - - // For clients, confirm they can see this project (RLS gate) if (profile.role === 'client') { const { data: proj, error: projErr } = await callerClient.from('projects').select('id').eq('id', projectId).single(); if (projErr || !proj) return res.status(404).json({ error: 'Project not found or access denied' }); } - // Cleanup storage files const { data: tasks } = await admin.from('tasks').select('id').eq('project_id', projectId); const taskIds = (tasks || []).map(t => t.id); @@ -190,17 +54,8 @@ export default async function handler(req, res) { } } - // Delete project (DB cascade handles tasks/submissions/etc.) const { error } = await admin.from('projects').delete().eq('id', projectId); if (error) return res.status(500).json({ error: error.message }); - // Archive FileBrowser project folder (server-side, so errors are logged) - try { - await archiveProject(projRecord.company?.name, projRecord.name); - } catch (e) { - console.error('[delete-project] archive failed:', e.message); - // Don't fail — DB delete succeeded - } - return res.status(200).json({ ok: true }); } diff --git a/api/delete-task.js b/api/delete-task.js index 656246c..0dc546b 100644 --- a/api/delete-task.js +++ b/api/delete-task.js @@ -1,126 +1,5 @@ import { createClient } from '@supabase/supabase-js'; -const FB_SOURCE = 'srv'; - -function normalizePath(path) { - const raw = String(path || '/').trim(); - const parts = raw.split('/').filter(Boolean); - const clean = []; - for (const part of parts) { - if (part === '.') continue; - if (part === '..') throw new Error('Invalid path'); - clean.push(part); - } - return `/${clean.join('/')}`; -} - -function joinPath(...parts) { - return normalizePath(parts.join('/')); -} - -function safeName(value) { - return String(value || '') - .trim() - .replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-') - .replace(/\s+/g, ' ') - .replace(/^-+|-+$/g, ''); -} - -function basename(path) { - const parts = normalizePath(path).split('/').filter(Boolean); - return parts[parts.length - 1] || ''; -} - -function getFbConfig() { - const url = String(process.env.FILEBROWSER_URL || '').trim().replace(/\/+$/, ''); - return { - url, - token: process.env.FILEBROWSER_TOKEN || '', - clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/Clients'), - archiveRoot: normalizePath(process.env.FILEBROWSER_ARCHIVE_ROOT || '/Archive'), - configured: Boolean(url) && Boolean(process.env.FILEBROWSER_TOKEN), - }; -} - -async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) { - const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString(); - const res = await fetch(`${config.url}${endpoint}?${qs}`, { - method, - headers: { Authorization: `Bearer ${config.token}`, ...headers }, - body, - }); - if (!res.ok) { - const text = await res.text(); - const err = new Error(text || `FileBrowser ${res.status}`); - err.status = res.status; - throw err; - } - const text = await res.text(); - try { return text ? JSON.parse(text) : null; } catch { return text; } -} - -async function fbExists(config, path) { - try { await fbFetch(config, 'GET', '/api/resources', { params: { path } }); return true; } - catch (e) { if (e.status === 404) return false; throw e; } -} - -async function fbMkdir(config, path) { - await fbFetch(config, 'POST', '/api/resources', { params: { path, isDir: 'true' } }).catch(() => {}); -} - -async function mergeMove(config, fbSrc, fbDstParent) { - const name = basename(fbSrc); - const fbDst = joinPath(fbDstParent, name); - const destExists = await fbExists(config, fbDst); - if (!destExists) { - await fbFetch(config, 'PATCH', '/api/resources', { - params: {}, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - action: 'move', - items: [{ fromSource: FB_SOURCE, fromPath: fbSrc, toSource: FB_SOURCE, toPath: fbDst }], - overwrite: false, - }), - }); - } else { - const data = await fbFetch(config, 'GET', '/api/resources', { params: { path: fbSrc } }); - const dirs = (data?.folders || []).map(f => f.name); - const files = (data?.files || []).map(f => f.name); - await fbMkdir(config, fbDst); - for (const dir of dirs) await mergeMove(config, joinPath(fbSrc, dir), fbDst); - for (const file of files) { - await fbFetch(config, 'PATCH', '/api/resources', { - params: {}, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - action: 'move', - items: [{ fromSource: FB_SOURCE, fromPath: joinPath(fbSrc, file), toSource: FB_SOURCE, toPath: joinPath(fbDst, file) }], - overwrite: true, - }), - }).catch(() => {}); - } - await fbFetch(config, 'DELETE', '/api/resources', { params: { path: fbSrc } }).catch(() => {}); - } -} - -async function archiveTask(companyName, projectName, taskTitle) { - const config = getFbConfig(); - if (!config.configured || !companyName || !projectName || !taskTitle) return; - const co = safeName(companyName); - const proj = safeName(projectName); - const task = safeName(taskTitle); - // Ensure archive dirs exist - await fbMkdir(config, config.archiveRoot); - await fbMkdir(config, joinPath(config.archiveRoot, 'Clients')); - await fbMkdir(config, joinPath(config.archiveRoot, 'Clients', co)); - await fbMkdir(config, joinPath(config.archiveRoot, 'Clients', co, 'Projects')); - await fbMkdir(config, joinPath(config.archiveRoot, 'Clients', co, 'Projects', proj)); - // Merge-move task folder into archive - const srcPath = joinPath(config.clientRoot, co, 'Projects', proj, task); - const dstParentPath = joinPath(config.archiveRoot, 'Clients', co, 'Projects', proj); - await mergeMove(config, srcPath, dstParentPath); -} - export default async function handler(req, res) { if (req.method !== 'DELETE') return res.status(405).json({ error: 'Method not allowed' }); @@ -148,16 +27,9 @@ export default async function handler(req, res) { auth: { persistSession: false, autoRefreshToken: false }, }); - // Fetch task + project + company before deletion using explicit joins - const { data: taskRecord } = await admin.from('tasks').select('id, title, project_id').eq('id', taskId).single(); + const { data: taskRecord } = await admin.from('tasks').select('id').eq('id', taskId).single(); if (!taskRecord) return res.status(404).json({ error: 'Task not found' }); - const { data: projectRecord } = await admin.from('projects').select('id, name, company_id').eq('id', taskRecord.project_id).single(); - const { data: companyRecord } = projectRecord?.company_id - ? await admin.from('companies').select('id, name').eq('id', projectRecord.company_id).single() - : { data: null }; - - // Cleanup storage files const { data: subs } = await admin.from('submissions').select('id').eq('task_id', taskId); const subIds = (subs || []).map(s => s.id); @@ -175,18 +47,8 @@ export default async function handler(req, res) { } } - // Delete task (DB cascade handles submissions/etc.) const { error } = await admin.from('tasks').delete().eq('id', taskId); if (error) return res.status(500).json({ error: error.message }); - // Archive FileBrowser task folder - let archiveError = null; - try { - await archiveTask(companyRecord?.name, projectRecord?.name, taskRecord.title); - } catch (e) { - archiveError = e.message; - console.error('[delete-task] archive failed:', e.message); - } - - return res.status(200).json({ ok: true, archiveError }); + return res.status(200).json({ ok: true }); } diff --git a/api/filebrowser.js b/api/filebrowser.js index 3aa10d6..5825dcd 100644 --- a/api/filebrowser.js +++ b/api/filebrowser.js @@ -1,6 +1,10 @@ import { createClient } from '@supabase/supabase-js'; const FB_SOURCE = 'srv'; +const PROJECT_DEFAULT_SUBFOLDERS = ['00 Project Files']; +const REQUEST_DEFAULT_SUBFOLDERS = ['Old Books', 'Working Files', 'Survey']; + +export const config = { api: { bodyParser: false, sizeLimit: '50mb' } }; function json(res, status, body) { res.status(status).setHeader('Content-Type', 'application/json'); @@ -24,17 +28,6 @@ function joinPath(...parts) { return normalizePath(parts.join('/')); } -function parentDir(path) { - const parts = normalizePath(path).split('/').filter(Boolean); - parts.pop(); - return `/${parts.join('/')}`; -} - -function basename(path) { - const parts = normalizePath(path).split('/').filter(Boolean); - return parts[parts.length - 1] || ''; -} - function safeName(value, fallback = '') { const cleaned = String(value || '') .trim() @@ -44,146 +37,67 @@ function safeName(value, fallback = '') { return cleaned || fallback; } +function parseBody(body) { + if (!body) return {}; + if (typeof body === 'string') { + try { + return JSON.parse(body); + } catch { + return {}; + } + } + return body; +} + +async function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); +} + function getConfig() { const url = String(process.env.FILEBROWSER_URL || '').trim().replace(/\/+$/, ''); return { url, token: process.env.FILEBROWSER_TOKEN || '', - adminUser: process.env.FILEBROWSER_ADMIN_USER || '', - adminPass: process.env.FILEBROWSER_ADMIN_PASS || '', - teamRoot: normalizePath(process.env.FILEBROWSER_TEAM_ROOT || '/'), clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients'), - externalSubsRoot: normalizePath(process.env.FILEBROWSER_SUBS_ROOT || '/fourgebranding/Subcontractors'), - externalClientsRoot: normalizePath(process.env.FILEBROWSER_CLIENTS_ROOT || '/fourgebranding/Clients'), + subsRoot: normalizePath(process.env.FILEBROWSER_SUBS_ROOT || '/fourgebranding/team'), configured: Boolean(url), }; } -let runtimeToken = ''; -let runtimeTokenTs = 0; -const RUNTIME_TOKEN_TTL_MS = 30 * 60 * 1000; - -function extractLoginToken(payload) { - if (typeof payload === 'string') { - const raw = payload.trim(); - if (raw.split('.').length === 3) return raw; - try { - const parsed = JSON.parse(raw); - return extractLoginToken(parsed); - } catch { - return ''; - } - } - if (!payload || typeof payload !== 'object') return ''; - return String( - payload.token - || payload.auth - || payload.access_token - || payload.jwt - || payload?.data?.token - || '' - ).trim(); -} - -async function loginForToken(config) { - if (!config.url || !config.adminUser || !config.adminPass) return ''; - const endpoints = ['/api/auth/login', '/api/login']; - let lastError = ''; - - for (const endpoint of endpoints) { - try { - const response = await fetch(`${config.url}${endpoint}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username: config.adminUser, password: config.adminPass }), - }); - if (!response.ok) { - const text = await response.text().catch(() => ''); - // Some FileBrowser deployments only expose /api/auth/login. - // Ignore /api/login 404 and keep trying/using prior success signal. - if (endpoint === '/api/login' && response.status === 404) continue; - lastError = `${endpoint} -> ${response.status} ${text?.slice(0, 180) || ''}`.trim(); - continue; - } - const bodyText = await response.text(); - const payload = (() => { - try { - return JSON.parse(bodyText); - } catch { - return bodyText; - } - })(); - const token = extractLoginToken(payload); - if (token) return token; - lastError = `${endpoint} -> 200 but token missing`; - } catch { - lastError = `${endpoint} -> request failed`; - } - } - - if (lastError) { - const err = new Error(`FileBrowser login failed: ${lastError}`); - err.status = 401; - throw err; - } - return ''; -} - -async function getToken(config, forceRefresh = false) { - const now = Date.now(); - if (!forceRefresh && config.token && String(config.token).trim()) { - return String(config.token).trim(); - } - if (!forceRefresh && runtimeToken && (now - runtimeTokenTs) < RUNTIME_TOKEN_TTL_MS) { - return runtimeToken; - } - - if (!forceRefresh) { - const freshPreferred = await loginForToken(config); - if (freshPreferred) { - runtimeToken = freshPreferred; - runtimeTokenTs = now; - return runtimeToken; - } - } - - const fresh = await loginForToken(config); - if (!fresh) throw new Error('FileBrowser login returned no token.'); - runtimeToken = fresh; - runtimeTokenTs = now; - return runtimeToken; +function getToken(config) { + const token = String(config.token || '').trim(); + if (!token) throw new Error('FILEBROWSER_TOKEN not configured'); + return token; } 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, + }); - async function callWith(token) { - return 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; } - let token = await getToken(config); - let res = await callWith(token); - - if (res.status === 401) { - // Always force-refresh and retry once on Unauthorized. - token = await getToken(config, true); - res = await callWith(token); + const text = await response.text(); + try { + return text ? JSON.parse(text) : null; + } catch { + return text; } - - if (!res.ok) { - const text = await res.text(); - const err = new Error(text || `FileBrowser ${res.status}`); - err.status = res.status; - throw err; - } - - const text = await res.text(); - try { return text ? JSON.parse(text) : null; } catch { return text; } } async function createCallerClient(authHeader) { @@ -203,17 +117,16 @@ async function requirePortalUser(authHeader) { const { data: profile, error: profileError } = await callerClient .from('profiles') - .select('id, name, role, company:companies(id, name)') + .select('id, role, company:companies(id, name)') .eq('id', userData.user.id) .single(); if (profileError) return { ok: false, status: 500, message: profileError.message }; - if (!['team', 'external', 'client'].includes(profile?.role)) { + if (!['team', 'client'].includes(profile?.role)) { return { ok: false, status: 403, message: 'Forbidden' }; } - // Mirror AuthContext: load all companies (FK + company_members) - let clientCompanies = []; + const clientCompanies = []; if (profile.role === 'client') { const seen = new Set(); if (profile.company?.id) { @@ -224,400 +137,275 @@ async function requirePortalUser(authHeader) { .from('company_members') .select('company:companies(id, name)') .eq('profile_id', userData.user.id); - for (const m of memberships || []) { - if (m.company?.id && !seen.has(m.company.id)) { - clientCompanies.push(m.company); - seen.add(m.company.id); + for (const row of memberships || []) { + if (row.company?.id && !seen.has(row.company.id)) { + seen.add(row.company.id); + clientCompanies.push(row.company); } } } - return { ok: true, callerClient, profile: { ...profile, clientCompanies, email: userData.user.email } }; -} - -async function getExternalProjects(callerClient, userId) { - const { data, error } = await callerClient - .from('project_members') - .select('project:projects(id, name, company:companies(name))') - .eq('profile_id', userId); - - if (error) throw new Error(error.message); - return (data || []).map(r => r.project).filter(Boolean); -} - -function resolveUserRoot(config, profile) { - if (profile.role === 'team') return config.teamRoot; - if (profile.role === 'client') { - const companyFolder = safeName(profile.company?.name, profile.id); - return joinPath(config.clientRoot, companyFolder); - } - if (profile.role === 'external') return config.externalSubsRoot; - return '/'; -} - -function resolveClientPath(config, vPath, companies) { - const parts = normalizePath(vPath).split('/').filter(Boolean); - if (parts.length === 0) return { virtual: true }; - - const companyFolder = parts[0]; - const match = companies.find(c => safeName(c.name, '') === companyFolder); - if (!match) { - const err = new Error('Access denied'); - err.status = 403; - throw err; - } - - const rest = parts.slice(1); - const base = joinPath(config.clientRoot, companyFolder); - const fbPath = rest.length > 0 ? joinPath(base, ...rest) : base; - return { virtual: false, fbPath }; -} - -function buildClientVirtualEntries(companies) { - return companies.map(c => { - const name = safeName(c.name, c.id); - return { name, type: 'dir', size: 0, mtime: null, path: `/${name}` }; - }); -} - -function resolveExternalPath(config, vPath, profile, projects) { - const myFolder = safeName(profile.name, profile.id); - const parts = normalizePath(vPath).split('/').filter(Boolean); - - if (parts.length === 0) return { virtual: true }; - - // Their personal Team folder - if (parts[0] === myFolder) { - const fbPath = joinPath(config.externalSubsRoot, ...parts); - return { virtual: false, fbPath }; - } - - // Assigned client projects — flattened: Projects/{project}/... - if (parts[0] === 'Projects') { - if (parts.length < 2) return { virtual: true }; - const [, projectFolder, ...rest] = parts; - const match = projects.find(p => safeName(p.name, '') === projectFolder); - if (!match) { - const err = new Error('Access denied to this project'); - err.status = 403; - throw err; - } - const company = safeName(match.company?.name, ''); - const base = joinPath(config.externalClientsRoot, company, 'Projects', projectFolder); - const fbPath = rest.length > 0 ? joinPath(base, ...rest) : base; - return { virtual: false, fbPath }; - } - - const err = new Error('Access denied'); - err.status = 403; - throw err; -} - -function buildExternalVirtualEntries(vPath, profile, projects) { - const myFolder = safeName(profile.name, profile.id); - const parts = normalizePath(vPath).split('/').filter(Boolean); - - if (parts.length === 0) { - const entries = [{ name: myFolder, type: 'dir', size: 0, mtime: null, path: `/${myFolder}` }]; - if (projects.length > 0) entries.push({ name: 'Projects', type: 'dir', size: 0, mtime: null, path: '/Projects' }); - return entries; - } - - if (parts[0] === 'Projects' && parts.length === 1) { - const seen = new Set(); - return projects - .map(p => safeName(p.name, '')) - .filter(name => name && !seen.has(name) && seen.add(name)) - .map(name => ({ name, type: 'dir', size: 0, mtime: null, path: `/Projects/${name}` })); - } - - return []; -} - -function normalizeQuantumItems(data, virtualPath) { - const dirs = (data?.folders || []).map(item => ({ ...item, _type: 'directory' })); - const files = (data?.files || []).map(item => ({ ...item, _type: item.type || 'file' })); - const items = [...dirs, ...files].map(item => ({ - name: item.name, - type: (item._type === 'directory' || item.type === 'directory') ? 'dir' : 'file', - size: (item._type === 'directory' || item.type === 'directory') ? 0 : (item.size || 0), - mtime: item.modified || null, - path: joinPath(virtualPath, item.name), - })); - return items.sort((a, b) => { - if (a.type !== b.type) return a.type === 'dir' ? -1 : 1; - if (a.name === '00 Project Files') return -1; - if (b.name === '00 Project Files') return 1; - return a.name.localeCompare(b.name); - }); -} - -function toListResponse(vPath, entries, { readOnly = false } = {}) { return { - configured: true, - path: vPath, - canGoUp: vPath !== '/', - parentPath: parentDir(vPath), - entries, - readOnly, + ok: true, + profile: { ...profile, clientCompanies }, }; } -export const config = { api: { bodyParser: false, sizeLimit: '50mb' } }; - -async function readBody(req) { - return new Promise((resolve, reject) => { - const chunks = []; - req.on('data', chunk => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); - req.on('end', () => resolve(Buffer.concat(chunks))); - req.on('error', reject); +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 }, }); } +function isAlreadyExistsError(error) { + const message = String(error?.message || '').toLowerCase(); + return error?.status === 409 || message.includes('exist') || message.includes('conflict'); +} + +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; + } + } +} + +async function renameDirectory(config, fromPath, toPath) { + const sourcePath = normalizePath(fromPath); + const targetPath = normalizePath(toPath); + if (sourcePath === targetPath) return { renamed: false, skipped: true, path: targetPath }; + + try { + await fbFetch(config, 'PATCH', '/api/resources', { + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'rename', + items: [{ fromSource: FB_SOURCE, fromPath: sourcePath, toSource: FB_SOURCE, toPath: targetPath }], + overwrite: false, + }), + }); + return { renamed: true, path: targetPath }; + } catch (error) { + if (error?.status === 404) { + await ensureDirectory(config, targetPath); + return { renamed: false, ensured: true, path: targetPath }; + } + throw error; + } +} + export default async function handler(req, res) { + const action = String(req.query?.action || '').trim(); + if (req.method !== 'POST' || ![ + 'ensure-company-folder', + 'ensure-project-folder', + 'ensure-request-folder', + 'ensure-profile-folder', + 'upload-request-file', + 'rename-company-folder', + 'rename-project-folder', + 'rename-profile-folder', + ].includes(action)) { + return json(res, 405, { error: 'Method not allowed' }); + } + + const authHeader = req.headers.authorization || ''; + if (!authHeader.startsWith('Bearer ')) { + return json(res, 401, { error: 'Unauthorized' }); + } + + const config = getConfig(); + if (!config.configured) { + return json(res, 200, { ok: false, configured: false, warning: 'FileBrowser is not configured.' }); + } + try { const rawBody = await readBody(req); const body = (() => { if (!rawBody.length) return {}; - const ct = (req.headers['content-type'] || '').split(';')[0].trim(); - if (ct === 'application/json') { try { return JSON.parse(rawBody.toString()); } catch { return {}; } } + const contentType = String(req.headers['content-type'] || '').split(';')[0].trim(); + if (contentType === 'application/json') { + try { + return JSON.parse(rawBody.toString()); + } catch { + return {}; + } + } return {}; })(); - - const queryToken = String(req.query?.sb_access_token || body?.sb_access_token || '').trim(); - const authHeader = req.headers.authorization || (queryToken ? `Bearer ${queryToken}` : ''); - if (!authHeader) return json(res, 401, { error: 'No authorization header' }); - const auth = await requirePortalUser(authHeader); if (!auth.ok) return json(res, auth.status, { error: auth.message }); - const config = getConfig(); - if (!config.configured || !config.token) { - return json(res, 200, { - configured: false, - error: 'FileBrowser not configured.', - requiredEnv: ['FILEBROWSER_URL', 'FILEBROWSER_TOKEN'], + const admin = createAdminClient(); + if (action === 'ensure-company-folder') { + if (auth.profile.role !== 'team') { + return json(res, 403, { error: 'Forbidden' }); + } + const companyId = String(body.companyId || '').trim(); + if (!companyId) return json(res, 400, { error: 'companyId is required.' }); + + const { data: company, error: companyError } = await admin + .from('companies') + .select('id, name') + .eq('id', companyId) + .single(); + if (companyError || !company) return json(res, 404, { error: 'Company not found.' }); + + const fullPath = joinPath(config.clientRoot, safeName(company.name, company.id)); + await ensureDirectory(config, fullPath); + return json(res, 200, { ok: true, configured: true, path: fullPath }); + } + + if (action === 'rename-company-folder') { + if (auth.profile.role !== 'team') { + return json(res, 403, { error: 'Forbidden' }); + } + const companyId = String(body.companyId || '').trim(); + const oldName = safeName(body.oldName, ''); + const newName = safeName(body.newName, ''); + if (!companyId || !oldName || !newName) return json(res, 400, { error: 'companyId, oldName, and newName are required.' }); + + const { data: company, error: companyError } = await admin + .from('companies') + .select('id') + .eq('id', companyId) + .single(); + if (companyError || !company) return json(res, 404, { error: 'Company not found.' }); + + const result = await renameDirectory(config, joinPath(config.clientRoot, oldName), joinPath(config.clientRoot, newName)); + return json(res, 200, { ok: true, configured: true, ...result }); + } + + if (action === 'ensure-profile-folder') { + if (auth.profile.role !== 'team') { + return json(res, 403, { error: 'Forbidden' }); + } + const profileId = String(body.profileId || '').trim(); + if (!profileId) return json(res, 400, { error: 'profileId is required.' }); + + const { data: profile, error: profileError } = await admin + .from('profiles') + .select('id, name, role') + .eq('id', profileId) + .single(); + if (profileError || !profile) return json(res, 404, { error: 'Profile not found.' }); + if (profile.role !== 'external') return json(res, 400, { error: 'Only subcontractor folders are supported here.' }); + + const fullPath = joinPath(config.subsRoot, safeName(profile.name, profile.id)); + await ensureDirectory(config, fullPath); + return json(res, 200, { ok: true, configured: true, path: fullPath }); + } + + if (action === 'rename-profile-folder') { + if (auth.profile.role !== 'team') { + return json(res, 403, { error: 'Forbidden' }); + } + const profileId = String(body.profileId || '').trim(); + const oldName = safeName(body.oldName, ''); + const newName = safeName(body.newName, ''); + if (!profileId || !oldName || !newName) return json(res, 400, { error: 'profileId, oldName, and newName are required.' }); + + const { data: profile, error: profileError } = await admin + .from('profiles') + .select('id, role') + .eq('id', profileId) + .single(); + if (profileError || !profile) return json(res, 404, { error: 'Profile not found.' }); + if (profile.role !== 'external') return json(res, 400, { error: 'Only subcontractor folders are supported here.' }); + + const result = await renameDirectory(config, joinPath(config.subsRoot, oldName), joinPath(config.subsRoot, newName)); + return json(res, 200, { ok: true, configured: true, ...result }); + } + + const projectId = String(req.query?.projectId || body.projectId || '').trim(); + if (!projectId) return json(res, 400, { error: 'projectId is required.' }); + + const { data: project, error: projectError } = await admin + .from('projects') + .select('id, name, company_id, company:companies(id, name)') + .eq('id', projectId) + .single(); + if (projectError || !project) return json(res, 404, { error: 'Project not found.' }); + + if (auth.profile.role === 'client') { + const allowedCompanyIds = new Set((auth.profile.clientCompanies || []).map(company => company.id)); + if (!allowedCompanyIds.has(project.company_id)) return json(res, 403, { error: 'Forbidden' }); + } + + const companyName = safeName(project.company?.name, String(project.company_id || 'company')); + const projectName = safeName(project.name, projectId); + + if (action === 'rename-project-folder') { + const oldName = safeName(body.oldName, ''); + const newName = safeName(body.newName, ''); + if (!oldName || !newName) return json(res, 400, { error: 'oldName and newName are required.' }); + const result = await renameDirectory( + config, + joinPath(config.clientRoot, companyName, 'Projects', oldName), + joinPath(config.clientRoot, companyName, 'Projects', newName) + ); + return json(res, 200, { ok: true, configured: true, ...result }); + } + + if (action === 'ensure-project-folder') { + const fullPath = joinPath(config.clientRoot, companyName, 'Projects', projectName); + await ensureDirectory(config, fullPath); + for (const folderName of PROJECT_DEFAULT_SUBFOLDERS) { + await ensureDirectory(config, joinPath(fullPath, folderName)); + } + return json(res, 200, { ok: true, configured: true, path: fullPath }); + } + + const taskTitle = safeName(req.query?.taskTitle || body.taskTitle, ''); + if (!taskTitle) return json(res, 400, { error: 'taskTitle is required.' }); + const fullPath = joinPath(config.clientRoot, companyName, 'Projects', projectName, taskTitle); + + if (action === 'upload-request-file') { + const bucket = String(body.bucket || 'submissions').trim(); + const storagePath = String(body.storagePath || '').trim(); + const fileName = safeName(body.fileName || req.query?.fileName || req.headers['x-file-name'], ''); + if (!storagePath) return json(res, 400, { error: 'storagePath is required.' }); + if (!fileName) return json(res, 400, { error: 'fileName is required.' }); + const surveyPath = joinPath(fullPath, 'Survey'); + await ensureDirectory(config, surveyPath); + const { data: downloadedFile, error: downloadError } = await admin.storage.from(bucket).download(storagePath); + if (downloadError || !downloadedFile) { + return json(res, 500, { error: downloadError?.message || 'Failed to download request file from storage.' }); + } + const fileBuffer = Buffer.from(await downloadedFile.arrayBuffer()); + const uploadUrl = `${config.url}/api/resources?source=${FB_SOURCE}&path=${encodeURIComponent(joinPath(surveyPath, fileName))}&override=true`; + const contentType = String(downloadedFile.type || '').trim() || 'application/octet-stream'; + const response = await fetch(uploadUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${getToken(config)}`, + 'Content-Type': contentType, + }, + body: fileBuffer, }); - } - - const action = req.query.action || (req.method === 'GET' ? 'list' : ''); - const requestedPath = req.query.path || body?.path || '/'; - - let externalProjects = []; - if (auth.profile.role === 'external') { - externalProjects = await getExternalProjects(auth.callerClient, auth.profile.id); - } - - function toFbPath(vPath = requestedPath) { - if (auth.profile.role === 'external') { - return resolveExternalPath(config, normalizePath(vPath), auth.profile, externalProjects); + if (!response.ok) { + const text = await response.text().catch(() => ''); + return json(res, response.status, { error: text || `Upload failed (${response.status})` }); } - if (auth.profile.role === 'client') { - return resolveClientPath(config, normalizePath(vPath), auth.profile.clientCompanies); - } - const root = resolveUserRoot(config, auth.profile); - return { virtual: false, fbPath: joinPath(root, normalizePath(vPath)) }; + return json(res, 200, { ok: true, configured: true, path: joinPath(surveyPath, fileName) }); } - if (req.method === 'GET' && action === 'config') { - return json(res, 200, { configured: true, role: auth.profile.role, url: config.url }); + await ensureDirectory(config, fullPath); + for (const folderName of REQUEST_DEFAULT_SUBFOLDERS) { + await ensureDirectory(config, joinPath(fullPath, folderName)); } - - if (req.method === 'GET' && action === 'list') { - const vPath = normalizePath(requestedPath); - if (auth.profile.role === 'external') { - const resolved = resolveExternalPath(config, vPath, auth.profile, externalProjects); - if (resolved.virtual) { - return json(res, 200, toListResponse(vPath, buildExternalVirtualEntries(vPath, auth.profile, externalProjects), { readOnly: true })); - } - const data = await fbFetch(config, 'GET', '/api/resources', { params: { path: resolved.fbPath } }); - return json(res, 200, toListResponse(vPath, normalizeQuantumItems(data, vPath))); - } - if (auth.profile.role === 'client') { - const resolved = resolveClientPath(config, vPath, auth.profile.clientCompanies); - if (resolved.virtual) { - return json(res, 200, toListResponse(vPath, buildClientVirtualEntries(auth.profile.clientCompanies))); - } - const data = await fbFetch(config, 'GET', '/api/resources', { params: { path: resolved.fbPath } }); - return json(res, 200, toListResponse(vPath, normalizeQuantumItems(data, vPath))); - } - const root = resolveUserRoot(config, auth.profile); - const fbPath = joinPath(root, vPath); - const data = await fbFetch(config, 'GET', '/api/resources', { params: { path: fbPath } }); - return json(res, 200, toListResponse(vPath, normalizeQuantumItems(data, vPath))); - } - - if (req.method === 'GET' && action === 'download') { - const resolved = toFbPath(); - if (resolved.virtual) return json(res, 400, { error: 'Cannot download virtual directory' }); - const token = await getToken(config); - const downloadUrl = `${config.url}/api/resources/download?source=${FB_SOURCE}&file=${encodeURIComponent(resolved.fbPath)}`; - return json(res, 200, { url: downloadUrl, token }); - } - - if (req.method === 'GET' && action === 'download-blob') { - const resolved = toFbPath(); - if (resolved.virtual) return json(res, 400, { error: 'Cannot download virtual directory' }); - const token = await getToken(config); - const downloadUrl = `${config.url}/api/resources/download?source=${FB_SOURCE}&file=${encodeURIComponent(resolved.fbPath)}&auth=${encodeURIComponent(token)}`; - let upstream = await fetch(downloadUrl); - if (upstream.status === 401) { - const fresh = await getToken(config, true); - const retryUrl = `${config.url}/api/resources/download?source=${FB_SOURCE}&file=${encodeURIComponent(resolved.fbPath)}&auth=${encodeURIComponent(fresh)}`; - upstream = await fetch(retryUrl); - } - if (!upstream.ok) { - const text = await upstream.text(); - return json(res, upstream.status, { error: text || `Download failed (${upstream.status})` }); - } - - res.status(upstream.status); - res.setHeader('Cache-Control', 'no-store'); - res.setHeader('Content-Type', upstream.headers.get('content-type') || 'application/octet-stream'); - res.setHeader('Content-Disposition', upstream.headers.get('content-disposition') || `attachment; filename="${basename(resolved.fbPath) || 'download'}"`); - const arrayBuffer = await upstream.arrayBuffer(); - return res.send(Buffer.from(arrayBuffer)); - } - - if (req.method === 'POST' && action === 'upload-token') { - const resolved = toFbPath(); - if (resolved.virtual) return json(res, 400, { error: 'Cannot upload to virtual directory' }); - const token = await getToken(config); - return json(res, 200, { token, url: config.url, fbPath: resolved.fbPath }); - } - - if (req.method === 'POST' && action === 'upload') { - const resolved = toFbPath(); - if (resolved.virtual) return json(res, 400, { error: 'Cannot upload to virtual directory' }); - const contentType = (req.headers['content-type'] || 'application/octet-stream').split(';')[0].trim(); - const uploadUrl = `${config.url}/api/resources?source=${FB_SOURCE}&path=${encodeURIComponent(resolved.fbPath)}&override=true`; - let token = await getToken(config); - let upRes = await fetch(uploadUrl, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': contentType }, body: rawBody }); - if (upRes.status === 401) { - token = await getToken(config, true); - upRes = await fetch(uploadUrl, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': contentType }, body: rawBody }); - } - if (!upRes.ok) { const t = await upRes.text(); return json(res, upRes.status, { error: t || `Upload failed (${upRes.status})` }); } - return json(res, 200, { success: true }); - } - - if (req.method === 'POST' && action === 'mkdir') { - const folderName = safeName(body?.name, ''); - if (!folderName) return json(res, 400, { error: 'Folder name required' }); - - const resolved = toFbPath(); - if (resolved.virtual) return json(res, 400, { error: 'Cannot create folder in virtual directory' }); - - await fbFetch(config, 'POST', '/api/resources', { - params: { path: joinPath(resolved.fbPath, folderName), isDir: 'true' }, - }); - return json(res, 200, { success: true }); - } - - if (req.method === 'DELETE' && action === 'delete') { - const resolved = toFbPath(); - if (resolved.virtual) return json(res, 400, { error: 'Cannot delete virtual directory' }); - if (!basename(resolved.fbPath)) return json(res, 400, { error: 'Cannot delete root' }); - - await fbFetch(config, 'DELETE', '/api/resources', { params: { path: resolved.fbPath } }); - return json(res, 200, { success: true }); - } - - if (req.method === 'POST' && action === 'rename') { - const newName = safeName(body?.name, ''); - if (!newName) return json(res, 400, { error: 'New name required' }); - - const resolved = toFbPath(); - if (resolved.virtual) return json(res, 400, { error: 'Cannot rename virtual directory' }); - - const newFbPath = joinPath(parentDir(resolved.fbPath), newName); - await fbFetch(config, 'PATCH', '/api/resources', { - params: {}, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - action: 'rename', - items: [{ fromSource: FB_SOURCE, fromPath: resolved.fbPath, toSource: FB_SOURCE, toPath: newFbPath }], - overwrite: false, - }), - }); - return json(res, 200, { success: true }); - } - - if (req.method === 'POST' && action === 'archive-move') { - // Moves srcPath into dstParentPath. If destination already exists, merges contents recursively. - const srcVPath = body?.srcPath; - const dstParentVPath = body?.dstParentPath; - if (!srcVPath || !dstParentVPath) return json(res, 400, { error: 'srcPath and dstParentPath required' }); - - const resolvedSrc = toFbPath(srcVPath); - const resolvedDstParent = toFbPath(dstParentVPath); - if (resolvedSrc.virtual || resolvedDstParent.virtual) return json(res, 400, { error: 'Cannot operate on virtual directories' }); - - async function fbExists(path) { - try { await fbFetch(config, 'GET', '/api/resources', { params: { path } }); return true; } - catch (e) { if (e.status === 404) return false; throw e; } - } - - async function fbMkdir(path) { - await fbFetch(config, 'POST', '/api/resources', { params: { path, isDir: 'true' } }).catch(() => {}); - } - - async function mergeMove(fbSrc, fbDstParent) { - const name = basename(fbSrc); - const fbDst = joinPath(fbDstParent, name); - const destExists = await fbExists(fbDst); - if (!destExists) { - await fbFetch(config, 'PATCH', '/api/resources', { - params: {}, headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action: 'move', items: [{ fromSource: FB_SOURCE, fromPath: fbSrc, toSource: FB_SOURCE, toPath: fbDst }], overwrite: false }), - }); - } else { - const data = await fbFetch(config, 'GET', '/api/resources', { params: { path: fbSrc } }); - const dirs = (data?.folders || []).map(f => f.name); - const files = (data?.files || []).map(f => f.name); - await fbMkdir(fbDst); - for (const dir of dirs) await mergeMove(joinPath(fbSrc, dir), fbDst); - for (const file of files) { - await fbFetch(config, 'PATCH', '/api/resources', { - params: {}, headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action: 'move', items: [{ fromSource: FB_SOURCE, fromPath: joinPath(fbSrc, file), toSource: FB_SOURCE, toPath: joinPath(fbDst, file) }], overwrite: true }), - }).catch(() => {}); - } - await fbFetch(config, 'DELETE', '/api/resources', { params: { path: fbSrc } }).catch(() => {}); - } - } - - await mergeMove(resolvedSrc.fbPath, resolvedDstParent.fbPath); - return json(res, 200, { success: true }); - } - - if (req.method === 'POST' && action === 'move') { - const srcPath = body?.srcPath; - const dstPath = body?.dstPath; - const mode = body?.mode === 'copy' ? 'copy' : 'move'; - if (!srcPath || !dstPath) return json(res, 400, { error: 'srcPath and dstPath required' }); - - const resolvedSrc = toFbPath(srcPath); - const resolvedDst = toFbPath(dstPath); - if (resolvedSrc.virtual || resolvedDst.virtual) return json(res, 400, { error: 'Cannot move virtual directories' }); - - const itemName = basename(resolvedSrc.fbPath); - const newFbPath = joinPath(resolvedDst.fbPath, itemName); - - await fbFetch(config, 'PATCH', '/api/resources', { - params: {}, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - action: mode, - items: [{ fromSource: FB_SOURCE, fromPath: resolvedSrc.fbPath, toSource: FB_SOURCE, toPath: newFbPath }], - overwrite: false, - }), - }); - return json(res, 200, { success: true }); - } - - return json(res, 405, { error: 'Method not allowed' }); + return json(res, 200, { ok: true, configured: true, path: fullPath }); } catch (error) { - return json(res, error.status || 500, { error: error.message || 'Unexpected error' }); + return json(res, error?.status || 500, { error: error?.message || 'Failed to ensure request folder.' }); } } diff --git a/api/migrate-old-books.js b/api/migrate-old-books.js deleted file mode 100644 index ceaf5e4..0000000 --- a/api/migrate-old-books.js +++ /dev/null @@ -1,144 +0,0 @@ -import { createClient } from '@supabase/supabase-js'; - -const FB_SOURCE = 'srv'; - -function normalizePath(path) { - const parts = String(path || '/').trim().split('/').filter(p => p && p !== '.' && p !== '..'); - return `/${parts.join('/')}`; -} - -function joinPath(...parts) { return normalizePath(parts.join('/')); } - -function safeName(v) { - return String(v || '').trim() - .replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-') - .replace(/\s+/g, ' ') - .replace(/^-+|-+$/g, ''); -} - -function getConfig() { - const url = String(process.env.FILEBROWSER_URL || '').trim().replace(/\/+$/, ''); - return { - url, - token: process.env.FILEBROWSER_TOKEN || '', - adminUser: process.env.FILEBROWSER_ADMIN_USER || '', - adminPass: process.env.FILEBROWSER_ADMIN_PASS || '', - clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/Clients'), - configured: Boolean(url), - }; -} - -let _cachedToken = ''; -let _cachedTokenTs = 0; - -async function getToken(config) { - if (config.token) return config.token; - const now = Date.now(); - if (_cachedToken && now - _cachedTokenTs < 25 * 60 * 1000) return _cachedToken; - const res = await fetch(`${config.url}/api/auth/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username: config.adminUser, password: config.adminPass }), - }); - const data = await res.json().catch(() => ({})); - const tok = data?.token || data?.access_token || ''; - if (tok) { _cachedToken = tok; _cachedTokenTs = now; } - return tok; -} - -async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) { - const token = await getToken(config); - const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString(); - const res = await fetch(`${config.url}${endpoint}?${qs}`, { - method, headers: { Authorization: `Bearer ${token}`, ...headers }, body, - }); - if (res.status === 401) { - _cachedToken = ''; - const fresh = await getToken(config); - return fetch(`${config.url}${endpoint}?${qs}`, { - method, headers: { Authorization: `Bearer ${fresh}`, ...headers }, body, - }); - } - return res; -} - -async function listDir(config, path) { - const res = await fbFetch(config, 'GET', '/api/resources', { params: { path } }); - if (!res.ok) return null; - const data = await res.json().catch(() => null); - const entries = Array.isArray(data?.entries) ? data.entries - : [...(data?.folders ?? []), ...(data?.files ?? [])]; - return entries; -} - -async function renameDir(config, fromPath, toPath) { - const res = await fbFetch(config, 'PATCH', '/api/resources', { - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action: 'rename', items: [{ fromSource: FB_SOURCE, fromPath, toSource: FB_SOURCE, toPath }], overwrite: true }), - }); - return res.ok; -} - -async function deleteDir(config, path) { - const res = await fbFetch(config, 'DELETE', '/api/resources', { params: { path } }); - return res.ok; -} - -export default async function handler(req, res) { - if (req.method !== 'POST') return res.status(405).json({ error: 'POST only' }); - - const authHeader = req.headers.authorization || ''; - const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || ''; - const supabaseUrl = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL; - const supabaseKey = process.env.VITE_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY; - const isServiceRole = serviceKey && authHeader === `Bearer ${serviceKey}`; - if (!isServiceRole) { - const sb = createClient(supabaseUrl, supabaseKey, { - auth: { persistSession: false }, - global: { headers: { Authorization: authHeader } }, - }); - const { data: { user } } = await sb.auth.getUser(); - if (!user) return res.status(401).json({ error: 'Unauthorized' }); - const { data: profile } = await sb.from('profiles').select('role').eq('id', user.id).single(); - if (profile?.role !== 'team') return res.status(403).json({ error: 'Team only' }); - } - const sb = createClient(supabaseUrl, serviceKey || supabaseKey, { auth: { persistSession: false } }); - - const config = getConfig(); - if (!config.configured) return res.status(500).json({ error: 'FileBrowser not configured' }); - - const { data: tasks, error } = await sb - .from('tasks') - .select('title, projects!project_id(name, companies!company_id(name))'); - if (error) return res.status(500).json({ error: error.message }); - - const log = []; - let migrated = 0, skipped = 0, failed = 0; - - for (const task of tasks || []) { - const co = safeName(task.projects?.companies?.name || ''); - const pr = safeName(task.projects?.name || ''); - const tk = safeName(task.title || ''); - if (!co || !pr || !tk) { skipped++; continue; } - - const base = joinPath(config.clientRoot, co, 'Projects', pr, tk); - const src = `${base}/Old Book`; - const dst = `${base}/Old Books`; - - const srcItems = await listDir(config, src); - if (srcItems === null) { skipped++; continue; } - - if (srcItems.length === 0) { - const ok = await deleteDir(config, src); - ok ? (migrated++, log.push(`deleted empty: ${src}`)) : (failed++, log.push(`delete-fail: ${src}`)); - } else { - for (const item of srcItems) { - await renameDir(config, `${src}/${item.name}`, `${dst}/${item.name}`); - } - const ok = await deleteDir(config, src); - ok ? (migrated++, log.push(`merged+deleted: ${src}`)) : (failed++, log.push(`merge-fail: ${src}`)); - } - } - - return res.status(200).json({ migrated, skipped, failed, log }); -} diff --git a/api/sync-company-folder.js b/api/sync-company-folder.js deleted file mode 100644 index 32628bd..0000000 --- a/api/sync-company-folder.js +++ /dev/null @@ -1,111 +0,0 @@ -const FB_SOURCE = 'srv'; - -function normalizePath(path) { - const raw = String(path || '/').trim(); - const parts = raw.split('/').filter(Boolean); - const clean = []; - for (const part of parts) { - if (part === '.') continue; - if (part === '..') throw new Error('Invalid path'); - clean.push(part); - } - return `/${clean.join('/')}`; -} - -function joinPath(...parts) { - return normalizePath(parts.join('/')); -} - -function parentDir(path) { - const parts = normalizePath(path).split('/').filter(Boolean); - parts.pop(); - return `/${parts.join('/')}`; -} - -function safeName(value) { - return String(value || '') - .trim() - .replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-') - .replace(/\s+/g, ' ') - .replace(/^-+|-+$/g, ''); -} - -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 || '/Clients'), - configured: Boolean(url), - }; -} - -async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) { - const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString(); - const res = await fetch(`${config.url}${endpoint}?${qs}`, { - method, - headers: { Authorization: `Bearer ${config.token}`, ...headers }, - body, - }); - if (!res.ok) { - const text = await res.text(); - const err = new Error(text || `FileBrowser ${res.status}`); - err.status = res.status; - throw err; - } -} - -async function mkdir(config, parentPath, name) { - const folderPath = joinPath(parentPath, safeName(name)); - await fbFetch(config, 'POST', '/api/resources', { - params: { path: folderPath, isDir: 'true' }, - }).catch(() => {}); -} - -async function renameFolder(config, oldName, newName) { - const oldSafe = safeName(oldName); - const newSafe = safeName(newName); - if (!oldSafe || !newSafe || oldSafe === newSafe) return; - const fromPath = joinPath(config.clientRoot, oldSafe); - const toPath = joinPath(config.clientRoot, newSafe); - await fbFetch(config, 'PATCH', '/api/resources', { - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - action: 'rename', - items: [{ fromSource: FB_SOURCE, fromPath, toSource: FB_SOURCE, toPath }], - overwrite: false, - }), - }).catch(() => {}); -} - -export default async function handler(req, res) { - if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' }); - - const secret = process.env.SUPABASE_WEBHOOK_SECRET; - if (secret) { - const incoming = req.headers['x-webhook-secret'] || req.headers['x-supabase-webhook-secret'] || ''; - if (incoming.trim() !== secret.trim()) return res.status(401).json({ error: 'Unauthorized' }); - } - - const { type, record, old_record } = req.body || {}; - if (!record?.name) return res.status(200).json({ ok: true, skipped: true }); - - const config = getConfig(); - if (!config.configured || !config.token) return res.status(200).json({ ok: true, skipped: 'not configured' }); - - const clientRoot = config.clientRoot; - const clientsParent = parentDir(clientRoot); - const clientsDirName = clientRoot.split('/').filter(Boolean).pop(); - - // Ensure parent Clients dir exists - await mkdir(config, clientsParent, clientsDirName); - - if (type === 'UPDATE' && old_record?.name && old_record.name !== record.name) { - await renameFolder(config, old_record.name, record.name); - } - - // Always ensure folder for current name exists (idempotent) - await mkdir(config, clientRoot, record.name); - - res.status(200).json({ ok: true, type, name: record.name }); -} diff --git a/api/sync-profile-folder.js b/api/sync-profile-folder.js deleted file mode 100644 index 73b89af..0000000 --- a/api/sync-profile-folder.js +++ /dev/null @@ -1,117 +0,0 @@ -const FB_SOURCE = 'srv'; -const MEMBER_ROLES = new Set(['team', 'external']); - -function normalizePath(path) { - const raw = String(path || '/').trim(); - const parts = raw.split('/').filter(Boolean); - const clean = []; - for (const part of parts) { - if (part === '.') continue; - if (part === '..') throw new Error('Invalid path'); - clean.push(part); - } - return `/${clean.join('/')}`; -} - -function joinPath(...parts) { - return normalizePath(parts.join('/')); -} - -function parentDir(path) { - const parts = normalizePath(path).split('/').filter(Boolean); - parts.pop(); - return `/${parts.join('/')}`; -} - -function safeName(value) { - return String(value || '') - .trim() - .replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-') - .replace(/\s+/g, ' ') - .replace(/^-+|-+$/g, ''); -} - -function getConfig() { - const url = String(process.env.FILEBROWSER_URL || '').trim().replace(/\/+$/, ''); - return { - url, - token: process.env.FILEBROWSER_TOKEN || '', - membersRoot: normalizePath(process.env.FILEBROWSER_MEMBERS_ROOT || '/Team'), - configured: Boolean(url), - }; -} - -async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) { - const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString(); - const res = await fetch(`${config.url}${endpoint}?${qs}`, { - method, - headers: { Authorization: `Bearer ${config.token}`, ...headers }, - body, - }); - if (!res.ok) { - const text = await res.text(); - const err = new Error(text || `FileBrowser ${res.status}`); - err.status = res.status; - throw err; - } -} - -async function mkdir(config, parentPath, name) { - const safe = safeName(name); - if (!safe) return; - await fbFetch(config, 'POST', '/api/resources', { - params: { path: joinPath(parentPath, safe), isDir: 'true' }, - }).catch(() => {}); -} - -async function renameFolder(config, root, oldName, newName) { - const oldSafe = safeName(oldName); - const newSafe = safeName(newName); - if (!oldSafe || !newSafe || oldSafe === newSafe) return; - await fbFetch(config, 'PATCH', '/api/resources', { - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - action: 'rename', - items: [{ - fromSource: FB_SOURCE, fromPath: joinPath(root, oldSafe), - toSource: FB_SOURCE, toPath: joinPath(root, newSafe), - }], - overwrite: false, - }), - }).catch(() => {}); -} - -export default async function handler(req, res) { - if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' }); - - const secret = process.env.SUPABASE_WEBHOOK_SECRET; - if (secret) { - const incoming = req.headers['x-webhook-secret'] || req.headers['x-supabase-webhook-secret'] || ''; - if (incoming.trim() !== secret.trim()) return res.status(401).json({ error: 'Unauthorized' }); - } - - const { type, record, old_record } = req.body || {}; - - // Only process team and external roles - if (!MEMBER_ROLES.has(record?.role)) return res.status(200).json({ ok: true, skipped: true }); - if (!record?.name) return res.status(200).json({ ok: true, skipped: 'no name' }); - - const config = getConfig(); - if (!config.configured || !config.token) return res.status(200).json({ ok: true, skipped: 'not configured' }); - - const membersRoot = config.membersRoot; - const membersParent = parentDir(membersRoot); - const membersDirName = membersRoot.split('/').filter(Boolean).pop(); - - // Ensure /fourgebranding/team dir exists - await mkdir(config, membersParent, membersDirName); - - if (type === 'UPDATE' && old_record?.name && old_record.name !== record.name) { - await renameFolder(config, membersRoot, old_record.name, record.name); - } - - // Ensure folder for current name exists - await mkdir(config, membersRoot, record.name); - - res.status(200).json({ ok: true, type, name: record.name, role: record.role }); -} diff --git a/api/sync-project-folder.js b/api/sync-project-folder.js deleted file mode 100644 index e06a8a0..0000000 --- a/api/sync-project-folder.js +++ /dev/null @@ -1,124 +0,0 @@ -import { createClient } from '@supabase/supabase-js'; - -const FB_SOURCE = 'srv'; - -function normalizePath(path) { - const raw = String(path || '/').trim(); - const parts = raw.split('/').filter(Boolean); - const clean = []; - for (const part of parts) { - if (part === '.') continue; - if (part === '..') throw new Error('Invalid path'); - clean.push(part); - } - return `/${clean.join('/')}`; -} - -function joinPath(...parts) { - return normalizePath(parts.join('/')); -} - -function safeName(value) { - return String(value || '') - .trim() - .replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-') - .replace(/\s+/g, ' ') - .replace(/^-+|-+$/g, ''); -} - -function getConfig() { - const url = String(process.env.FILEBROWSER_URL || '').trim().replace(/\/+$/, ''); - return { - url, - token: process.env.FILEBROWSER_TOKEN || '', - clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/Clients'), - configured: Boolean(url), - }; -} - -async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) { - const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString(); - const res = await fetch(`${config.url}${endpoint}?${qs}`, { - method, - headers: { Authorization: `Bearer ${config.token}`, ...headers }, - body, - }); - if (!res.ok) { - const text = await res.text(); - const err = new Error(text || `FileBrowser ${res.status}`); - err.status = res.status; - throw err; - } -} - -async function mkdir(config, path) { - await fbFetch(config, 'POST', '/api/resources', { - params: { path, isDir: 'true' }, - }).catch(() => {}); -} - -async function renameFolder(config, fromPath, toPath) { - await fbFetch(config, 'PATCH', '/api/resources', { - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - action: 'rename', - items: [{ fromSource: FB_SOURCE, fromPath, toSource: FB_SOURCE, toPath }], - overwrite: false, - }), - }).catch(() => {}); -} - -async function requireTeamUser(authHeader) { - const supabaseUrl = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL; - const supabaseAnonKey = process.env.VITE_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY; - if (!supabaseUrl || !supabaseAnonKey) return false; - const client = createClient(supabaseUrl, supabaseAnonKey, { - auth: { persistSession: false, autoRefreshToken: false }, - global: { headers: { Authorization: authHeader } }, - }); - const { data: userData } = await client.auth.getUser(); - if (!userData?.user) return false; - const { data: profile } = await client.from('profiles').select('role').eq('id', userData.user.id).single(); - return profile?.role === 'team'; -} - -export default async function handler(req, res) { - if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' }); - - const authHeader = req.headers.authorization || ''; - const secret = process.env.SUPABASE_WEBHOOK_SECRET; - - if (authHeader.startsWith('Bearer ')) { - const isTeam = await requireTeamUser(authHeader); - if (!isTeam) return res.status(403).json({ error: 'Team members only' }); - } else if (secret) { - const incoming = req.headers['x-webhook-secret'] || req.headers['x-supabase-webhook-secret'] || ''; - if (incoming.trim() !== secret.trim()) return res.status(401).json({ error: 'Unauthorized' }); - } - - const { type, record, old_record } = req.body || {}; - if (!record?.name || !record?.company_name) return res.status(200).json({ ok: true, skipped: 'missing name or company_name' }); - - const config = getConfig(); - if (!config.configured || !config.token) return res.status(200).json({ ok: true, skipped: 'not configured' }); - - const companyDir = joinPath(config.clientRoot, safeName(record.company_name)); - const projectsDir = joinPath(companyDir, 'Projects'); - - // Ensure Clients/{company}/Projects/ exists - await mkdir(config, companyDir); - await mkdir(config, projectsDir); - - if (type === 'UPDATE' && old_record?.name && old_record.name !== record.name) { - const oldPath = joinPath(projectsDir, safeName(old_record.name)); - const newPath = joinPath(projectsDir, safeName(record.name)); - await renameFolder(config, oldPath, newPath); - } - - // Ensure folder for current project name exists - const projectDir = joinPath(projectsDir, safeName(record.name)); - await mkdir(config, projectDir); - await mkdir(config, joinPath(projectDir, '00 Project Files')); - - res.status(200).json({ ok: true, type, company: record.company_name, project: record.name }); -} diff --git a/api/sync-task-folder.js b/api/sync-task-folder.js deleted file mode 100644 index ba3f3ee..0000000 --- a/api/sync-task-folder.js +++ /dev/null @@ -1,106 +0,0 @@ -const FB_SOURCE = 'srv'; - -function normalizePath(path) { - const raw = String(path || '/').trim(); - const parts = raw.split('/').filter(Boolean); - const clean = []; - for (const part of parts) { - if (part === '.') continue; - if (part === '..') throw new Error('Invalid path'); - clean.push(part); - } - return `/${clean.join('/')}`; -} - -function joinPath(...parts) { - return normalizePath(parts.join('/')); -} - -function safeName(value) { - return String(value || '') - .trim() - .replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-') - .replace(/\s+/g, ' ') - .replace(/^-+|-+$/g, ''); -} - -function getConfig() { - const url = String(process.env.FILEBROWSER_URL || '').trim().replace(/\/+$/, ''); - return { - url, - token: process.env.FILEBROWSER_TOKEN || '', - clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/Clients'), - configured: Boolean(url), - }; -} - -async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) { - const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString(); - const res = await fetch(`${config.url}${endpoint}?${qs}`, { - method, - headers: { Authorization: `Bearer ${config.token}`, ...headers }, - body, - }); - if (!res.ok) { - const text = await res.text(); - const err = new Error(text || `FileBrowser ${res.status}`); - err.status = res.status; - throw err; - } -} - -async function mkdir(config, path) { - await fbFetch(config, 'POST', '/api/resources', { - params: { path, isDir: 'true' }, - }).catch(() => {}); -} - -async function renameFolder(config, fromPath, toPath) { - await fbFetch(config, 'PATCH', '/api/resources', { - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - action: 'rename', - items: [{ fromSource: FB_SOURCE, fromPath, toSource: FB_SOURCE, toPath }], - overwrite: false, - }), - }).catch(() => {}); -} - -export default async function handler(req, res) { - if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' }); - - const secret = process.env.SUPABASE_WEBHOOK_SECRET; - if (secret) { - const incoming = req.headers['x-webhook-secret'] || req.headers['x-supabase-webhook-secret'] || ''; - if (incoming.trim() !== secret.trim()) return res.status(401).json({ error: 'Unauthorized' }); - } - - const { type, record, old_record } = req.body || {}; - if (!record?.title || !record?.project_name || !record?.company_name) { - return res.status(200).json({ ok: true, skipped: 'missing title, project_name, or company_name' }); - } - - const config = getConfig(); - if (!config.configured || !config.token) return res.status(200).json({ ok: true, skipped: 'not configured' }); - - const projectDir = joinPath(config.clientRoot, safeName(record.company_name), 'Projects', safeName(record.project_name)); - - // Ensure parent dirs exist - await mkdir(config, joinPath(config.clientRoot, safeName(record.company_name))); - await mkdir(config, joinPath(config.clientRoot, safeName(record.company_name), 'Projects')); - await mkdir(config, projectDir); - - if (type === 'UPDATE' && old_record?.title && old_record.title !== record.title) { - const oldPath = joinPath(projectDir, safeName(old_record.title)); - const newPath = joinPath(projectDir, safeName(record.title)); - await renameFolder(config, oldPath, newPath); - } - - const taskDir = joinPath(projectDir, safeName(record.title)); - await mkdir(config, taskDir); - await mkdir(config, joinPath(taskDir, 'Working Files')); - await mkdir(config, joinPath(taskDir, 'Request Info')); - await mkdir(config, joinPath(taskDir, 'Old Books')); - - res.status(200).json({ ok: true, type, company: record.company_name, project: record.project_name, task: record.title }); -} diff --git a/docs/checked-sites-revision-audit-2026-06-04.html b/docs/checked-sites-revision-audit-2026-06-04.html new file mode 100644 index 0000000..581856f --- /dev/null +++ b/docs/checked-sites-revision-audit-2026-06-04.html @@ -0,0 +1,686 @@ + + + + + + Checked Sites Revision Audit + + + +
+
+
Fourge Portal Audit
+

Checked Sites Revision Audit

+

This report treats your checkmark as “R00 was completed,” then separates that from billing and from any newer active revisions. It is designed to make client-billing comparison easier at a glance.

+
+ +
+
+
Checked Sites
+
26
+
Sites found in the database from your checked list.
+
+
+
Active Revisions
+
16
+
Checked sites that now have an active R01+.
+
+
+
R00 Billed
+
23
+
Checked sites where the new-book unit is already on an invoice.
+
+
+
R00 Unbilled
+
3
+
Checked sites completed at R00 but not yet billed.
+
+
+ +
+
+

Active Revision Status

+ +
+
not_started
+
+
12
+
+ +
+
client_review
+
+
3
+
+ +
+
client_approved
+
+
1
+
+ +
+
+

Active Revision Depth

+ +
+
R01
+
+
10
+
+ +
+
R02
+
+
6
+
+ +
+
+ +
+

Sites With Active Revisions

+

These are the ones where R00 may be done, but a newer version is still active or awaiting review/approval.

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Site #DB TitleInternal #R00 DoneR00 BilledActive R#Active StatusCompleted RevisionsBilled VersionsVersion Summary
0028600286 Riverside, CA31YesINV-2026-006 (sent)YesR01not_startedR00: INV-2026-006 (sent)R00=completed/billed; R01=not_started
3005530055 Moreno Valley, CA33YesINV-2026-006 (sent)YesR01client_reviewR00: INV-2026-006 (sent)R00=completed/billed; R01=client_review
3009630096 Eerie, CO36YesINV-2026-006 (sent)YesR02not_startedR01 (unbilled)R00: INV-2026-006 (sent)R00=completed/billed; R01=completed; R02=not_started
6863968639 NSA Los Lunas NM40YesINV-2026-006 (sent)YesR01client_reviewR00: INV-2026-006 (sent)R00=completed/billed; R01=client_review
6866368663 NSA Oklahoma City OK41YesINV-2026-006 (sent)YesR01not_startedR00: INV-2026-006 (sent)R00=completed/billed; R01=not_started
6866468664 NSA Oklahoma City OK42YesINV-2026-006 (sent)YesR01not_startedR00: INV-2026-006 (sent)R00=completed/billed; R01=not_started
6866668666 NSA Oklahoma City, OK43YesINV-2026-006 (sent)YesR01not_startedR00: INV-2026-006 (sent)R00=completed/billed; R01=not_started
6866968669 NSA Oklahoma City OK44YesINV-2026-006 (sent)YesR01not_startedR00: INV-2026-006 (sent)R00=completed/billed; R01=not_started
6867068670 NSA Oklahoma City OK45YesINV-2026-006 (sent)YesR02not_startedR01 (billed)R00: INV-2026-006 (sent) | R01: INV-2026-006 (sent)R00=completed/billed; R01=completed/billed; R02=not_started
6867168671 NSA Oklahoma City OK46YesINV-2026-006 (sent)YesR01not_startedR00: INV-2026-006 (sent)R00=completed/billed; R01=not_started
6867368673 NSA Oklahoma City OK47YesINV-2026-006 (sent)YesR01client_reviewR00: INV-2026-006 (sent)R00=completed/billed; R01=client_review
6872168721 NSA Mechanicsburg PA48YesINV-2026-006 (sent)YesR02client_approvedR01 (billed), R02 (unbilled)R00: INV-2026-006 (sent) | R01: INV-2026-006 (sent)R00=completed/billed; R01=completed/billed; R02=completed
6880868808 Camas, WA49YesINV-2026-006 (sent)YesR01not_startedR00: INV-2026-006 (sent)R00=completed/billed; R01=not_started
6880968809 Centralia, WA50YesINV-2026-006 (sent)YesR02not_startedR01 (billed)R00: INV-2026-006 (sent) | R01: INV-2026-006 (sent)R00=completed/billed; R01=completed/billed; R02=not_started
6881068810 Chehalis, WA51YesINV-2026-006 (sent)YesR02not_startedR01 (billed)R00: INV-2026-006 (sent) | R01: INV-2026-006 (sent)R00=completed/billed; R01=completed/billed; R02=not_started
6881168811 Kelso, WA52YesINV-2026-006 (sent)YesR02not_startedR01 (unbilled)R00: INV-2026-006 (sent)R00=completed/billed; R01=completed; R02=not_started
+
+
+ +
+

Checked Sites With No Active Revision

+

These are simpler to read: R00 completed, and no current revision chain is active right now.

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Site #DB TitleInternal #R00 DoneR00 BilledActive R#Active StatusCompleted RevisionsBilled VersionsVersion Summary
3006230062 Riverside, CA34YesINV-2026-006 (sent)NoR00: INV-2026-006 (sent)R00=completed/billed
3009430094 Colorado Springs, CO35YesNoNoR00=completed
3025830258 Lebanon, NH38YesINV-2026-006 (sent)NoR00: INV-2026-006 (sent)R00=completed/billed
3025930259 Hamburg, NJ39YesINV-2026-006 (sent)NoR00: INV-2026-006 (sent)R00=completed/billed
3026130261 NSA Clovis NM70YesNoNoR00=completed
6872068720 NSA Lancaster PA74YesNoNoR00=completed
6872468724 NSA York PA75YesINV-2026-006 (sent)NoR00: INV-2026-006 (sent)R00=completed/billed
6875268752 NSA Brownsville TX76YesINV-2026-006 (sent)NoR00: INV-2026-006 (sent)R00=completed/billed
6875368753 NSA Brownsville TX77YesINV-2026-006 (sent)NoR00: INV-2026-006 (sent)R00=completed/billed
6875868758 NSA Brownsville TX81YesINV-2026-006 (sent)NoR00: INV-2026-006 (sent)R00=completed/billed
+
+
+
+ + \ No newline at end of file diff --git a/docs/checked-sites-revision-audit-2026-06-04.json b/docs/checked-sites-revision-audit-2026-06-04.json new file mode 100644 index 0000000..236322b --- /dev/null +++ b/docs/checked-sites-revision-audit-2026-06-04.json @@ -0,0 +1,340 @@ +[ + { + "site": "00286", + "dbTitle": "00286 Riverside, CA", + "internalTaskNumber": 31, + "r00Completed": "Yes", + "r00Billed": "INV-2026-006 (sent)", + "hasActiveRevision": "Yes", + "activeRevision": "R01", + "activeStatus": "not_started", + "completedRevisions": "—", + "billedVersions": "R00: INV-2026-006 (sent)", + "versionSummary": "R00=completed/billed; R01=not_started" + }, + { + "site": "30055", + "dbTitle": "30055 Moreno Valley, CA", + "internalTaskNumber": 33, + "r00Completed": "Yes", + "r00Billed": "INV-2026-006 (sent)", + "hasActiveRevision": "Yes", + "activeRevision": "R01", + "activeStatus": "client_review", + "completedRevisions": "—", + "billedVersions": "R00: INV-2026-006 (sent)", + "versionSummary": "R00=completed/billed; R01=client_review" + }, + { + "site": "30062", + "dbTitle": "30062 Riverside, CA", + "internalTaskNumber": 34, + "r00Completed": "Yes", + "r00Billed": "INV-2026-006 (sent)", + "hasActiveRevision": "No", + "activeRevision": "—", + "activeStatus": "—", + "completedRevisions": "—", + "billedVersions": "R00: INV-2026-006 (sent)", + "versionSummary": "R00=completed/billed" + }, + { + "site": "30094", + "dbTitle": "30094 Colorado Springs, CO", + "internalTaskNumber": 35, + "r00Completed": "Yes", + "r00Billed": "No", + "hasActiveRevision": "No", + "activeRevision": "—", + "activeStatus": "—", + "completedRevisions": "—", + "billedVersions": "—", + "versionSummary": "R00=completed" + }, + { + "site": "30096", + "dbTitle": "30096 Eerie, CO", + "internalTaskNumber": 36, + "r00Completed": "Yes", + "r00Billed": "INV-2026-006 (sent)", + "hasActiveRevision": "Yes", + "activeRevision": "R02", + "activeStatus": "not_started", + "completedRevisions": "R01 (unbilled)", + "billedVersions": "R00: INV-2026-006 (sent)", + "versionSummary": "R00=completed/billed; R01=completed; R02=not_started" + }, + { + "site": "30258", + "dbTitle": "30258 Lebanon, NH", + "internalTaskNumber": 38, + "r00Completed": "Yes", + "r00Billed": "INV-2026-006 (sent)", + "hasActiveRevision": "No", + "activeRevision": "—", + "activeStatus": "—", + "completedRevisions": "—", + "billedVersions": "R00: INV-2026-006 (sent)", + "versionSummary": "R00=completed/billed" + }, + { + "site": "30259", + "dbTitle": "30259 Hamburg, NJ", + "internalTaskNumber": 39, + "r00Completed": "Yes", + "r00Billed": "INV-2026-006 (sent)", + "hasActiveRevision": "No", + "activeRevision": "—", + "activeStatus": "—", + "completedRevisions": "—", + "billedVersions": "R00: INV-2026-006 (sent)", + "versionSummary": "R00=completed/billed" + }, + { + "site": "30261", + "dbTitle": "30261 NSA Clovis NM", + "internalTaskNumber": 70, + "r00Completed": "Yes", + "r00Billed": "No", + "hasActiveRevision": "No", + "activeRevision": "—", + "activeStatus": "—", + "completedRevisions": "—", + "billedVersions": "—", + "versionSummary": "R00=completed" + }, + { + "site": "68639", + "dbTitle": "68639 NSA Los Lunas NM", + "internalTaskNumber": 40, + "r00Completed": "Yes", + "r00Billed": "INV-2026-006 (sent)", + "hasActiveRevision": "Yes", + "activeRevision": "R01", + "activeStatus": "client_review", + "completedRevisions": "—", + "billedVersions": "R00: INV-2026-006 (sent)", + "versionSummary": "R00=completed/billed; R01=client_review" + }, + { + "site": "68663", + "dbTitle": "68663 NSA Oklahoma City OK", + "internalTaskNumber": 41, + "r00Completed": "Yes", + "r00Billed": "INV-2026-006 (sent)", + "hasActiveRevision": "Yes", + "activeRevision": "R01", + "activeStatus": "not_started", + "completedRevisions": "—", + "billedVersions": "R00: INV-2026-006 (sent)", + "versionSummary": "R00=completed/billed; R01=not_started" + }, + { + "site": "68664", + "dbTitle": "68664 NSA Oklahoma City OK", + "internalTaskNumber": 42, + "r00Completed": "Yes", + "r00Billed": "INV-2026-006 (sent)", + "hasActiveRevision": "Yes", + "activeRevision": "R01", + "activeStatus": "not_started", + "completedRevisions": "—", + "billedVersions": "R00: INV-2026-006 (sent)", + "versionSummary": "R00=completed/billed; R01=not_started" + }, + { + "site": "68666", + "dbTitle": "68666 NSA Oklahoma City, OK", + "internalTaskNumber": 43, + "r00Completed": "Yes", + "r00Billed": "INV-2026-006 (sent)", + "hasActiveRevision": "Yes", + "activeRevision": "R01", + "activeStatus": "not_started", + "completedRevisions": "—", + "billedVersions": "R00: INV-2026-006 (sent)", + "versionSummary": "R00=completed/billed; R01=not_started" + }, + { + "site": "68669", + "dbTitle": "68669 NSA Oklahoma City OK", + "internalTaskNumber": 44, + "r00Completed": "Yes", + "r00Billed": "INV-2026-006 (sent)", + "hasActiveRevision": "Yes", + "activeRevision": "R01", + "activeStatus": "not_started", + "completedRevisions": "—", + "billedVersions": "R00: INV-2026-006 (sent)", + "versionSummary": "R00=completed/billed; R01=not_started" + }, + { + "site": "68670", + "dbTitle": "68670 NSA Oklahoma City OK", + "internalTaskNumber": 45, + "r00Completed": "Yes", + "r00Billed": "INV-2026-006 (sent)", + "hasActiveRevision": "Yes", + "activeRevision": "R02", + "activeStatus": "not_started", + "completedRevisions": "R01 (billed)", + "billedVersions": "R00: INV-2026-006 (sent) | R01: INV-2026-006 (sent)", + "versionSummary": "R00=completed/billed; R01=completed/billed; R02=not_started" + }, + { + "site": "68671", + "dbTitle": "68671 NSA Oklahoma City OK", + "internalTaskNumber": 46, + "r00Completed": "Yes", + "r00Billed": "INV-2026-006 (sent)", + "hasActiveRevision": "Yes", + "activeRevision": "R01", + "activeStatus": "not_started", + "completedRevisions": "—", + "billedVersions": "R00: INV-2026-006 (sent)", + "versionSummary": "R00=completed/billed; R01=not_started" + }, + { + "site": "68673", + "dbTitle": "68673 NSA Oklahoma City OK", + "internalTaskNumber": 47, + "r00Completed": "Yes", + "r00Billed": "INV-2026-006 (sent)", + "hasActiveRevision": "Yes", + "activeRevision": "R01", + "activeStatus": "client_review", + "completedRevisions": "—", + "billedVersions": "R00: INV-2026-006 (sent)", + "versionSummary": "R00=completed/billed; R01=client_review" + }, + { + "site": "68720", + "dbTitle": "68720 NSA Lancaster PA", + "internalTaskNumber": 74, + "r00Completed": "Yes", + "r00Billed": "No", + "hasActiveRevision": "No", + "activeRevision": "—", + "activeStatus": "—", + "completedRevisions": "—", + "billedVersions": "—", + "versionSummary": "R00=completed" + }, + { + "site": "68721", + "dbTitle": "68721 NSA Mechanicsburg PA", + "internalTaskNumber": 48, + "r00Completed": "Yes", + "r00Billed": "INV-2026-006 (sent)", + "hasActiveRevision": "Yes", + "activeRevision": "R02", + "activeStatus": "client_approved", + "completedRevisions": "R01 (billed), R02 (unbilled)", + "billedVersions": "R00: INV-2026-006 (sent) | R01: INV-2026-006 (sent)", + "versionSummary": "R00=completed/billed; R01=completed/billed; R02=completed" + }, + { + "site": "68724", + "dbTitle": "68724 NSA York PA", + "internalTaskNumber": 75, + "r00Completed": "Yes", + "r00Billed": "INV-2026-006 (sent)", + "hasActiveRevision": "No", + "activeRevision": "—", + "activeStatus": "—", + "completedRevisions": "—", + "billedVersions": "R00: INV-2026-006 (sent)", + "versionSummary": "R00=completed/billed" + }, + { + "site": "68752", + "dbTitle": "68752 NSA Brownsville TX", + "internalTaskNumber": 76, + "r00Completed": "Yes", + "r00Billed": "INV-2026-006 (sent)", + "hasActiveRevision": "No", + "activeRevision": "—", + "activeStatus": "—", + "completedRevisions": "—", + "billedVersions": "R00: INV-2026-006 (sent)", + "versionSummary": "R00=completed/billed" + }, + { + "site": "68753", + "dbTitle": "68753 NSA Brownsville TX", + "internalTaskNumber": 77, + "r00Completed": "Yes", + "r00Billed": "INV-2026-006 (sent)", + "hasActiveRevision": "No", + "activeRevision": "—", + "activeStatus": "—", + "completedRevisions": "—", + "billedVersions": "R00: INV-2026-006 (sent)", + "versionSummary": "R00=completed/billed" + }, + { + "site": "68758", + "dbTitle": "68758 NSA Brownsville TX", + "internalTaskNumber": 81, + "r00Completed": "Yes", + "r00Billed": "INV-2026-006 (sent)", + "hasActiveRevision": "No", + "activeRevision": "—", + "activeStatus": "—", + "completedRevisions": "—", + "billedVersions": "R00: INV-2026-006 (sent)", + "versionSummary": "R00=completed/billed" + }, + { + "site": "68808", + "dbTitle": "68808 Camas, WA", + "internalTaskNumber": 49, + "r00Completed": "Yes", + "r00Billed": "INV-2026-006 (sent)", + "hasActiveRevision": "Yes", + "activeRevision": "R01", + "activeStatus": "not_started", + "completedRevisions": "—", + "billedVersions": "R00: INV-2026-006 (sent)", + "versionSummary": "R00=completed/billed; R01=not_started" + }, + { + "site": "68809", + "dbTitle": "68809 Centralia, WA", + "internalTaskNumber": 50, + "r00Completed": "Yes", + "r00Billed": "INV-2026-006 (sent)", + "hasActiveRevision": "Yes", + "activeRevision": "R02", + "activeStatus": "not_started", + "completedRevisions": "R01 (billed)", + "billedVersions": "R00: INV-2026-006 (sent) | R01: INV-2026-006 (sent)", + "versionSummary": "R00=completed/billed; R01=completed/billed; R02=not_started" + }, + { + "site": "68810", + "dbTitle": "68810 Chehalis, WA", + "internalTaskNumber": 51, + "r00Completed": "Yes", + "r00Billed": "INV-2026-006 (sent)", + "hasActiveRevision": "Yes", + "activeRevision": "R02", + "activeStatus": "not_started", + "completedRevisions": "R01 (billed)", + "billedVersions": "R00: INV-2026-006 (sent) | R01: INV-2026-006 (sent)", + "versionSummary": "R00=completed/billed; R01=completed/billed; R02=not_started" + }, + { + "site": "68811", + "dbTitle": "68811 Kelso, WA", + "internalTaskNumber": 52, + "r00Completed": "Yes", + "r00Billed": "INV-2026-006 (sent)", + "hasActiveRevision": "Yes", + "activeRevision": "R02", + "activeStatus": "not_started", + "completedRevisions": "R01 (unbilled)", + "billedVersions": "R00: INV-2026-006 (sent)", + "versionSummary": "R00=completed/billed; R01=completed; R02=not_started" + } +] \ No newline at end of file diff --git a/docs/checked-sites-revision-audit-2026-06-04.md b/docs/checked-sites-revision-audit-2026-06-04.md new file mode 100644 index 0000000..bb58e21 --- /dev/null +++ b/docs/checked-sites-revision-audit-2026-06-04.md @@ -0,0 +1,32 @@ +# Checked Sites Revision Audit + +Definition used: your checkbox means `R00` was completed at some point, not that the task has no active revisions now. + +| Site # | DB Title | Internal # | R00 Completed | R00 Billed | Active Revision? | Active R# | Active Status | Completed Revisions | Billed Versions | Version Summary | +|---|---|---:|---|---|---|---|---|---|---|---| +| 00286 | — | — | — | — | — | — | — | — | — | Missing from DB | +| 30055 | — | — | — | — | — | — | — | — | — | Missing from DB | +| 30062 | — | — | — | — | — | — | — | — | — | Missing from DB | +| 30094 | — | — | — | — | — | — | — | — | — | Missing from DB | +| 30096 | — | — | — | — | — | — | — | — | — | Missing from DB | +| 30258 | — | — | — | — | — | — | — | — | — | Missing from DB | +| 30259 | — | — | — | — | — | — | — | — | — | Missing from DB | +| 30261 | — | — | — | — | — | — | — | — | — | Missing from DB | +| 68639 | — | — | — | — | — | — | — | — | — | Missing from DB | +| 68663 | — | — | — | — | — | — | — | — | — | Missing from DB | +| 68664 | — | — | — | — | — | — | — | — | — | Missing from DB | +| 68666 | — | — | — | — | — | — | — | — | — | Missing from DB | +| 68669 | — | — | — | — | — | — | — | — | — | Missing from DB | +| 68670 | — | — | — | — | — | — | — | — | — | Missing from DB | +| 68671 | — | — | — | — | — | — | — | — | — | Missing from DB | +| 68673 | — | — | — | — | — | — | — | — | — | Missing from DB | +| 68720 | — | — | — | — | — | — | — | — | — | Missing from DB | +| 68721 | — | — | — | — | — | — | — | — | — | Missing from DB | +| 68724 | — | — | — | — | — | — | — | — | — | Missing from DB | +| 68752 | — | — | — | — | — | — | — | — | — | Missing from DB | +| 68753 | — | — | — | — | — | — | — | — | — | Missing from DB | +| 68758 | — | — | — | — | — | — | — | — | — | Missing from DB | +| 68808 | — | — | — | — | — | — | — | — | — | Missing from DB | +| 68809 | — | — | — | — | — | — | — | — | — | Missing from DB | +| 68810 | — | — | — | — | — | — | — | — | — | Missing from DB | +| 68811 | — | — | — | — | — | — | — | — | — | Missing from DB | \ No newline at end of file diff --git a/docs/checked-sites-revision-audit-2026-06-04.pdf b/docs/checked-sites-revision-audit-2026-06-04.pdf new file mode 100644 index 0000000..b55509d --- /dev/null +++ b/docs/checked-sites-revision-audit-2026-06-04.pdf @@ -0,0 +1,118 @@ +%PDF-1.4 +% ReportLab Generated PDF document (opensource) +1 0 obj +<< +/F1 2 0 R /F2 3 0 R /F3 4 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font +>> +endobj +5 0 obj +<< +/Contents 11 0 R /MediaBox [ 0 0 1008 612 ] /Parent 10 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +6 0 obj +<< +/Contents 12 0 R /MediaBox [ 0 0 1008 612 ] /Parent 10 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +7 0 obj +<< +/Contents 13 0 R /MediaBox [ 0 0 1008 612 ] /Parent 10 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +8 0 obj +<< +/PageMode /UseNone /Pages 10 0 R /Type /Catalog +>> +endobj +9 0 obj +<< +/Author (\(anonymous\)) /CreationDate (D:20260604232324-04'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260604232324-04'00') /Producer (ReportLab PDF Library - \(opensource\)) + /Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False +>> +endobj +10 0 obj +<< +/Count 3 /Kids [ 5 0 R 6 0 R 7 0 R ] /Type /Pages +>> +endobj +11 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 3045 +>> +stream +Gb"/+D/\/u')q<+0u0K5RuUWJF-Gn.U9a!OJ8ddL%-cJ]''O3IH4\'+'Oti&n.FVg'4@^[uoO,W.VRUmqq]VTt]2n44fR0EQk5m/EI0j?UJ8_4NE+USn*G#5Pf]5qE\V?5pFQ.BOE0[+KTkRBqHZ$[1F"IH^]Q"M^Q$#C@i&pYkUdYb:QD;Qc9KmcC]0L%9o90gsMJ+O]-,oSG$6S&0ieKc+gq*fT[KVu.IYM^k>d5Q@dO(Xj#jDQet5V,8dL@['F4c29q-CDA_@5nCrSWkkX$j6ce-SQ/M2nuS:trUb7;6Z)39Rsfm)%[W.E4s=m#mGW0XH(7t3oipbt:lY#Yk5"1VQK_s\UTB'.ZfqUqMXe2)D]gR6D+@X`6^cin1WY;NnQ1`SXUF8'**7Z&>j(_dPo)]AE*eXYEdb4?3eFgVOWN)9^@p_njtFi:A<2=W+Ej54KF&r'L/s]U,+&iN,UinZUi8Hn:MCeTFCE0#uiS*$J/Ei`@oPKVo2s'eNA!#@am,u7dM)2HqB"E3uArE*'sVgiqWHHm.AJR%"/GDnKl2fW4BF.hSCU-[`qFj]7!$Gq$gS8/%CD&i>*Om\4Gkc9'?>d,gscL%g+G@XMH2;mdjna$jSDAoiM_h2[1)/Bdt_;(L8NZ+.o&C*NmP1)PhQ8iEug6B.[B!)s-9b?,9o/hn,=P5J_2>QjQ"ritgYN^+RNMjdoho1tX($!GiD=!,N;4ic+)`ZN1.A3#2tY2*RM"`30Pb9[;UPH2oi0I/l0`4]!6$W@?s1@%c7,?S)4NGB9:q\7AUOHg`bFpj"nuQ*(@0S;9B=Oij?`BCd9dMOT7M=6baZ?=[YV+3o#KV;2s=q;'hqrPd]VXb%G9DL4?Sq6e"HSLJas*oaoTZ?!0dO.bcm5frC(Q'FQa`CoE@H5?'A9;A]B3G*hb6EB_`!*>"iE=`q=XKr#3Tn\@?j=TeV4>q-*-C[WQSjKmAt-*h6`r,Bg:r,sj;B(r>U9S"1t@?DEfSVV1rFjCQ.d7XaD.uqh)hOqjt+3h'H2cV5H)WO:3oJ)YN`RdYV#CE!Q'QhHeG!Sr17SlQaM(B5H?)^0/;FQ\:FeP73Tc,BWmjPRI/='gY^7ga^d!oL=RqOG-Iq(=CM0tK^^22C)Y:5T3D*6BdS>enpZNf$cqqo&HcJIPW)rQR5@.d0>5br9@M9[dME%1+)(hm,G`+$jd;N=O+HAjQ2,G^>i/5%F1VHiQ7oICKn']gn:q$-geb1^p^4.Q"5,$,/t6-;]QjW5X;N*-Zp$Z^(%=)D5eS19oH55_^:@P7;G-IY3q?rQ-H=`Y^-[5g/'p\%UA!k]&9)sGipL(?\qmf%pAa9YUmqg`)Rc'#I`d8q@W`h]>soETl`VN>]Q0sYlWuP5\MDgNkSNJ"sDd=jmHu"EGu'o>@*uEL(F-q-ng7^VMc8gkRW1U\E;@n$5G8c28H%o,U$.&!&5DNoR%0EVK4`-/[II-rmJn'6!#X/(K\OcCUldYf>\OkA#F\:n/utaDs%ZI%+a&k:p.MZL'VTiW1=$M_ifUVnQco'$=q%&=QKg`cu?kTVf=V`:,4dWWof4[B0UV26Vn#DY%J1Lgfqr47(kL-#T>d?n3\3YgUEM7=2$4IKS6&^ZU8,9JimgF/2/r;%VeO:=Cr,j@YUQ!([^AgQ5Gk=V=%3Q`"/hV'lH%X>;Gc^k3?][I4IZrb)\[l:6lMF)\fNZs1b9lmVi:+g"%ij1IaRg&3i:Y($)6GhNNJt/TRkQd$$GNpIG3KoX5B!a*TpQ`s4ojArVEf[B4h,NdO\f#`([jVLu+]4Gs["IF6NX]f'l6?0V&"cJAMX2K>M#a3Gf'k$mqb^;*P86M-aco`a!Nqp?F,5IQ!TI]^lhlZV*F`n11O?.8EAS[H$NgV,mHlY^P8?J2DZ2@JWZm_u'jNMU5F]cu3GgcuOLGt&=TAi%FdEA"Q!#9uk9&^tJa\Bo/SlOhlt0VVOBm"f@5,=3#%@U%)J/E;OH]61.lOTst?Li)r"qh?Qdr#aH1lVR~>endstream +endobj +12 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 2064 +>> +stream +Gb!l"D/\/e&H;*)EPT;u7AKEZWS4kgS^)CV%+pWunO97D=Jh'sl"8O\6LcHFn*]^/i[LZgn3cThZO+G+!$4>!5T'to!>U<=_+XQ;d/1.N?ju\(A6Z$9GdkJ\,#f!ENVS@Kk;=oAmrjbLh[-e9%g;ZnnYC#-:MK^XpAh2@>a$bAm2o`>/mQW6e5%kmY^bG(:,hPQ$h>#d>*QMbLML%cc^5ZK\2+!!)m>ObQ'$[9:kogI)MfH3]W^S(PBmW$Le?eCY$pSk!A)+RXO1Jgfi9A4fgGi5rXND#Cj)P%&;]V435ZcAce^9ZYSt%B%=2D9CEOSSd,qtD@Z-m??PJqK\3UbHGPfflnk[O+*>VGB'E*Qb0k+diK[pE4cJn'1d@B5NL/d9L,3+-*&73orSQpkeUqF18,?^V!Stc&(:AHU9(XN1D,W>eBr&Y4JkP97t?'l?1PGPf3m&^fnQ`Ik;Z1g+/]p5p[c@C[YXQuYV`(!`uA+&D-b8qd_[`4E8(>"1N/4d8"+'_DloJ3dnLn".`;LL_;BbNHkcG3KAej&4*VtLU*$!b"SpD)m`5)r5j2B#Hb:QhX2mc/Jn65IRN`\'[rkW,DWpKO-E@N-9$rVHU]!8E3';Wpp4dUniWm6N.EZ?*B_P%fW,.]>MM@p_->tY3'2=?OZEGXl+WI]iIS]O?"Ac9*@C]Nmhi9"g\j1?>e$B0hT&!ra,57-EZJoL!Q=pBA=22;sQs]t\,"iL>TLnq(EU-c%A$QQ;K@gseP@rnfHLh$9^)GPZJif==FR]LqGKLSEe'[Q.,5Z[aH];$f>/:nPPH7GTZIF>TWi`b0l^7fS>,M)&5!'i]7q_E(Qa>#S)/l+BQm/^AKMF3)eT?#'JsL6AQm+24dNu!-!4p8g[f]IC"MN(s_lmE[(K[`0`R:[p`lOYtQKqZaT]KepqrMq;8f*asQJTB+SL_;s\#sciB?/kOCkNZ]kGo:k]!JYS,H`d;9s$lE)GZ%Y#OkrdH*WkhWYG&D]sGAhWdSBJXOp,5:Ek%,Y(i3##AgV+TkqoHftY*Wah=,o'eDt%AgZ`eNh-O_cccM,*23CF_sqEM&K>7<=a'fWJS,(jZb);km2HpRL!Bm,&@/Vq2"IMpXkof^Irga#c$/Xe?0*Y`Bq"mF/Oa^N$70Y5?CrIECp_=*C9?iO9Q[A1G.>%.pTM#j'g-2:Bq]2&La?ODfSW](s/oapH,Y!C-o?"uMPaaTjP?k5$8:]7r*lFL8krl-G=lOTQg?2mls7R[Do!q%RUfKf,jUfSEQQ5h5Q4<0X#hn;"fZjdjr-/gV($&(8;_hF@.2&YIh6saqZE8hD=RJ%Im_O85sQT'?2oq97rpq,n6lSH%d&gcKhdcr-e%iaD09!@+pPs+/>A\K27cr/UCBi`kk"=`@V/C:l$9T(_GY,ER>pa:/PJ16Xl:3`'oF!(q#H;i^.+-K#C*3`;"T!>Pj!.5En[d"o~>endstream +endobj +13 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 2151 +>> +stream +Gb!lc>BA7Q'Z],&.F-/O'S[N3XP;%e""h[t"'oRrlnZIO851$o'@q0XiS3g`I#15Mf^9`H-FBV7-!C7hUnaP%fpDlZD]X@gY5jT;nG!k+/n"G\GT=[VHj,!0#QG5WVjXeE6nVNa=o=2m=UaQ6@$WagQj&I^V?qWr3/D%(B4*l:A'+7')8:Ls6Gs$:(>2"VZ[#u>Me*SA#D0e]$4bpO/eX6V@/(Uo%t3!;BM_hj(8/%DhAaS5&P>@-6NSHMZK-]fYFLllfuMDAE#"<;-6+"4-K?c$3LigLA-)^(Z:d0F_io'u\Ok^9\X=uqo@]!9hqJb/_N(bDYLueHKa;9!09\g+,OEG:14aFRe05`p?&gZW?`.TnbGO1THk*K.#+$PkE!3N.2G%>AgRAuP7U$=f_;KVl%O7!i?n,'b0AL3+_^m+OI*4?9`h=s(HDQL<[T5h_iS]TbE&]JTo3p^IW6We*H&`H'hUHKqq_lXNg)2D8Eaq#+DJtc?\D:2GIFHMCkI-k[FjC9RH>g&B`lf6Uh,B#5R,hfiBY\f7!9b?FU7>1@T@b]?NZj\R\p7RMie2j4_Hru+90D?lU'riS],^`TQ2p;$6`LBar\B\_?UX\72[DKs!Md'kaVaRn+qZ8nVJ#]K6g$kX,c@dOQqiaHomj*%^U%m_aYlVk%Ml3Ti&4r-T'mZuEJq6%(gtg[F-uCDUpW@A0H-kLD+JX;*RRS9!2!mBdkj_XQZolR'],s)>>J&Qe/Q@8fRr`D?Zh.G[L(rYUt0g>e7K0,dYJG-lpHc-.[DO-dn?KQf-jc_pAVKHJ"T^8@+I`<&@*G:VlkrDmd.=)/.c25W,`LWcaK,,sQ=c-SX57]2Z`A."dYq783ghXL>:F7f3bb++!ICk(G8TOsPIq:MJ!O?UCI5QH?>fp'G.Jh"[8=:@e>V#@Q/%73<.n>pa3e]eHNY_B>7"$iE3m"WTnV<,5'-J!?&"Q_:)@YHdBga7-CsU2W6P!`\d0[+^ZT/fGES?]Y(c:MOd>DFSr^$3*)G`VP6IJhDWNsZbD*Fp?&QDXHf`tqmB+aT8/I:f?+Iim-_8pr3<&LP$WN;sG26NadLpHiBkp*mZO0/p9BKg4$\WsdRZ0gq/4nepleP<>T2"(r:+kB%DM$K2gH/5q`^ptCf/du.;,'$d]7B`jaVC0iPj8`lXpD3J4>bg\DrZ6L]_U*L2'`X$%X>0DB6WT83oN09h=UWpoL%;,l?lC7a,V#(Z3BX#,=];X"56>[2U0((UL!D<7=0F6D;/>b4)*Vp5^O5,]8Lj\$/YdNZKEH_58b.12k$XFb,iQ*%qQna08h\endstream +endobj +xref +0 14 +0000000000 65535 f +0000000061 00000 n +0000000112 00000 n +0000000219 00000 n +0000000331 00000 n +0000000436 00000 n +0000000632 00000 n +0000000828 00000 n +0000001024 00000 n +0000001093 00000 n +0000001373 00000 n +0000001445 00000 n +0000004582 00000 n +0000006738 00000 n +trailer +<< +/ID +[] +% ReportLab generated PDF document -- digest (opensource) + +/Info 9 0 R +/Root 8 0 R +/Size 14 +>> +startxref +8981 +%%EOF diff --git a/docs/finance-summary.html b/docs/finance-summary.html new file mode 100644 index 0000000..126200b --- /dev/null +++ b/docs/finance-summary.html @@ -0,0 +1,339 @@ + + + + + + Fourge Portal Finance Summary + + + +
+
+

Fourge Portal Finance Summary

+

Role cheat sheet, finance lanes, and data flow for team, client, and subcontractor users.

+
+ +
+

1. Executive Summary

+

The finance area is not one shared page for all roles. It is split into three lanes with different permissions and different page sets.

+
+
+

Team

+

Full finance hub at /invoices.

+

Handles client invoices, expenses, subcontractor invoices, and subcontractor purchase orders.

+
+
+

Client

+

Invoice-only lane at /my-invoices.

+

Clients can view invoices, filter by company, and download PDFs.

+
+
+

Subcontractor

+

Two finance lanes: /my-invoices-sub and /my-purchase-orders.

+

They can submit invoices to Fourge and approve POs sent by Fourge.

+
+
+
+ +
+

2. Role-by-Role Cheat Sheet

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RoleMain RoutesCan DoCannot Do
Team/invoicesCreate/send client invoices, track expenses, create POs, review subcontractor invoices, mark paid, export/send PDFsNot limited in finance UI
Client/my-invoicesView invoices, filter by company, download invoice PDFsNo expenses, no invoice editing, no POs, no subcontractor finance tools
Subcontractor / External/my-invoices-sub
/my-purchase-orders
Create/submit invoices to Fourge, view status, download receipts after payment, approve POsNo team finance overview, no client invoice management, no internal expenses
+
+ +
+

3. Core Data Models

+
+
invoices
+
invoice_items
+
expenses
+
subcontractor_invoices
+
subcontractor_invoice_items
+
subcontractor_payments
+
subcontractor_po_items
+
tasks
+
submissions
+
companies
+
profiles
+

Simple mental model: invoices bill clients, expenses track internal costs, subcontractor_invoices let subcontractors bill Fourge, and subcontractor_payments are POs Fourge sends to subcontractors.

+
+
+ +
+

4. System Flow

+
+
+

Client Billing Flow

+

Approved client work reaches tasks.status = client_approved.

+

Team finance loads uninvoiced tasks and revisions, builds line items, saves an invoice, and emails the PDF to the client.

+

Later, team marks the invoice paid.

+
+
+

Expense Flow

+

Team logs expenses, optionally with receipt files.

+

Expenses feed overview charts, yearly summaries, and profit calculations.

+
+
+

Subcontractor Invoice Flow

+

Subcontractor creates an invoice from completed approved tasks assigned to them.

+

Fourge reviews it, then marks it paid and sends a receipt PDF back.

+
+
+

Purchase Order Flow

+

Team creates a PO for a subcontractor, optionally tied to project tasks.

+

Subcontractor reviews the PO and can approve it from their own portal view.

+
+
+
+ +
+

5. Status Logic

+
+
+

Client Invoice

+

draftsentpaid

+
+
+

Subcontractor Invoice

+

draftsubmittedpaid

+
+
+

Purchase Order

+

draftsentapprovedready_to_paypaid

+

cancelled is exit state.

+
+
+
+ +
+

6. Key Business Rules

+
    +
  • Client invoices only pull approved, uninvoiced work.
  • +
  • Tasks and revisions are marked invoiced after invoice creation.
  • +
  • Deleting a team invoice rolls linked invoice flags back.
  • +
  • Team invoice status changes also push task status changes.
  • +
  • Revision billing rule: R00 = new work, R01 = free, R02+ = billable increments.
  • +
  • Subcontractor invoices are created by the external user and then reviewed/paid by team.
  • +
  • POs are created by team and approved by the subcontractor.
  • +
+
+ +
+

7. Database Relationship Chart

+
COMPANIES + └─ invoices + └─ invoice_items + ├─ task_id → tasks + └─ submission_id → submissions + +PROJECTS + └─ tasks + └─ submissions + +PROFILES (external) + ├─ subcontractor_invoices + │ └─ subcontractor_invoice_items + │ └─ task_id → tasks + └─ subcontractor_payments (POs) + └─ subcontractor_po_items + └─ task_id → tasks + +EXPENSES + └─ standalone internal finance records
+
+ +
+

8. Quick Route Map

+ + + + + + + + + + + + + + + +
SurfaceRouteUser
Finance Hub/invoicesTeam
Client Invoices/my-invoicesClient
Subcontractor Invoices/my-invoices-subExternal
Create Subcontractor Invoice/my-invoices-sub/newExternal
Purchase Orders/my-purchase-ordersExternal
+
+ + +
+ + diff --git a/docs/finance-summary.pdf b/docs/finance-summary.pdf new file mode 100644 index 0000000..8fb8f25 Binary files /dev/null and b/docs/finance-summary.pdf differ diff --git a/docs/generate-checked-sites-audit-html.mjs b/docs/generate-checked-sites-audit-html.mjs new file mode 100644 index 0000000..99a2c8b --- /dev/null +++ b/docs/generate-checked-sites-audit-html.mjs @@ -0,0 +1,379 @@ +import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const docsDir = new URL('.', import.meta.url); +const inputPath = new URL('./checked-sites-revision-audit-2026-06-04.json', docsDir); +const outputPath = new URL('./checked-sites-revision-audit-2026-06-04.html', docsDir); + +const rows = JSON.parse(fs.readFileSync(inputPath, 'utf8')); + +const existingRows = rows.filter((row) => row.exists !== false); +const activeRows = existingRows.filter((row) => row.hasActiveRevision === 'Yes'); +const noActiveRows = existingRows.filter((row) => row.hasActiveRevision !== 'Yes'); +const r00BilledRows = existingRows.filter((row) => row.r00Billed !== 'No'); +const r00UnbilledRows = existingRows.filter((row) => row.r00Billed === 'No'); + +const activeStatusCounts = activeRows.reduce((acc, row) => { + const key = row.activeStatus || 'unknown'; + acc[key] = (acc[key] || 0) + 1; + return acc; +}, {}); + +const revisionDepthCounts = activeRows.reduce((acc, row) => { + const key = row.activeRevision || '—'; + acc[key] = (acc[key] || 0) + 1; + return acc; +}, {}); + +const maxStatusCount = Math.max(1, ...Object.values(activeStatusCounts)); +const maxDepthCount = Math.max(1, ...Object.values(revisionDepthCounts)); + +const esc = (value) => + String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + +function badgeClassForStatus(status) { + if (status === 'client_review') return 'review'; + if (status === 'client_approved') return 'approved'; + if (status === 'not_started') return 'todo'; + if (status === 'in_progress') return 'progress'; + return 'neutral'; +} + +function barChart(entries, maxValue) { + return entries + .map(([label, count]) => { + const pct = Math.max(4, Math.round((count / maxValue) * 100)); + return ` +
+
${esc(label)}
+
+
${count}
+
+ `; + }) + .join(''); +} + +function renderTableRows(items) { + return items + .map((row) => { + const billedClass = row.r00Billed === 'No' ? 'warn' : 'ok'; + const activeClass = badgeClassForStatus(row.activeStatus); + return ` + + ${esc(row.site)} + ${esc(row.dbTitle)} + ${esc(row.internalTaskNumber)} + ${esc(row.r00Completed)} + ${esc(row.r00Billed)} + ${esc(row.hasActiveRevision)} + ${esc(row.activeRevision)} + ${esc(row.activeStatus)} + ${esc(row.completedRevisions)} + ${esc(row.billedVersions)} + ${esc(row.versionSummary)} + + `; + }) + .join(''); +} + +const html = ` + + + + + Checked Sites Revision Audit + + + +
+
+
Fourge Portal Audit
+

Checked Sites Revision Audit

+

This report treats your checkmark as “R00 was completed,” then separates that from billing and from any newer active revisions. It is designed to make client-billing comparison easier at a glance.

+
+ +
+
+
Checked Sites
+
${existingRows.length}
+
Sites found in the database from your checked list.
+
+
+
Active Revisions
+
${activeRows.length}
+
Checked sites that now have an active R01+.
+
+
+
R00 Billed
+
${r00BilledRows.length}
+
Checked sites where the new-book unit is already on an invoice.
+
+
+
R00 Unbilled
+
${r00UnbilledRows.length}
+
Checked sites completed at R00 but not yet billed.
+
+
+ +
+
+

Active Revision Status

+ ${barChart(Object.entries(activeStatusCounts), maxStatusCount)} +
+
+

Active Revision Depth

+ ${barChart(Object.entries(revisionDepthCounts), maxDepthCount)} +
+
+ +
+

Sites With Active Revisions

+

These are the ones where R00 may be done, but a newer version is still active or awaiting review/approval.

+
+ + + + + + + + + + + + + + + + + ${renderTableRows(activeRows)} + +
Site #DB TitleInternal #R00 DoneR00 BilledActive R#Active StatusCompleted RevisionsBilled VersionsVersion Summary
+
+
+ +
+

Checked Sites With No Active Revision

+

These are simpler to read: R00 completed, and no current revision chain is active right now.

+
+ + + + + + + + + + + + + + + + + ${renderTableRows(noActiveRows)} + +
Site #DB TitleInternal #R00 DoneR00 BilledActive R#Active StatusCompleted RevisionsBilled VersionsVersion Summary
+
+
+
+ +`; + +fs.writeFileSync(outputPath, html); +console.log(fileURLToPath(outputPath)); diff --git a/docs/generate-checked-sites-audit-pdf.py b/docs/generate-checked-sites-audit-pdf.py new file mode 100644 index 0000000..705b919 --- /dev/null +++ b/docs/generate-checked-sites-audit-pdf.py @@ -0,0 +1,362 @@ +from pathlib import Path +import json + +from reportlab.lib import colors +from reportlab.lib.enums import TA_LEFT +from reportlab.lib.pagesizes import landscape, legal +from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet +from reportlab.lib.units import inch +from reportlab.platypus import ( + BaseDocTemplate, + Frame, + LongTable, + PageBreak, + PageTemplate, + Paragraph, + Spacer, + Table, + TableStyle, +) + + +DOCS_DIR = Path(__file__).resolve().parent +INPUT_PATH = DOCS_DIR / "checked-sites-revision-audit-2026-06-04.json" +OUTPUT_PATH = DOCS_DIR / "checked-sites-revision-audit-2026-06-04.pdf" + +BG = colors.HexColor("#F5F1E8") +TEXT = colors.HexColor("#231D14") +MUTED = colors.HexColor("#6B6255") +ACCENT = colors.HexColor("#B8831F") +ACCENT_SOFT = colors.HexColor("#F1E3C2") +OK = colors.HexColor("#1F8A4C") +OK_SOFT = colors.HexColor("#E5F4EB") +WARN = colors.HexColor("#B45309") +WARN_SOFT = colors.HexColor("#F7E7D7") +REVIEW = colors.HexColor("#2563EB") +REVIEW_SOFT = colors.HexColor("#E6EEFF") +TODO = colors.HexColor("#7C3AED") +TODO_SOFT = colors.HexColor("#F0E8FF") +LINE = colors.HexColor("#D7CFC1") + + +def page_template(canvas, doc): + canvas.saveState() + canvas.setFillColor(BG) + canvas.rect(0, 0, doc.pagesize[0], doc.pagesize[1], fill=1, stroke=0) + canvas.setFillColor(MUTED) + canvas.setFont("Helvetica", 9) + canvas.drawRightString(doc.pagesize[0] - 36, 20, f"Page {doc.page}") + canvas.restoreState() + + +def pill(text, fg, bg): + return Paragraph( + ( + f'{escape(text)}' + ), + styles["pill"], + ) + + +def escape(value): + return ( + str(value or "") + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + ) + + +def status_pill(value): + value = value or "—" + if value == "client_review": + return pill(value, REVIEW, REVIEW_SOFT) + if value == "client_approved": + return pill(value, OK, OK_SOFT) + if value == "not_started": + return pill(value, TODO, TODO_SOFT) + if value == "in_progress": + return pill(value, ACCENT, ACCENT_SOFT) + if value == "Yes": + return pill(value, OK, OK_SOFT) + if value == "No": + return pill(value, WARN, WARN_SOFT) + return pill(value, MUTED, colors.white) + + +def text_para(value, style_name="cell"): + return Paragraph(escape(value), styles[style_name]) + + +def build_summary_cards(rows): + existing_rows = [row for row in rows if row.get("exists", True) is not False] + active_rows = [row for row in existing_rows if row.get("hasActiveRevision") == "Yes"] + billed_rows = [row for row in existing_rows if row.get("r00Billed") != "No"] + unbilled_rows = [row for row in existing_rows if row.get("r00Billed") == "No"] + + cards = [ + ("Checked Sites", str(len(existing_rows)), "Sites found in the database from your checked list."), + ("Active Revisions", str(len(active_rows)), "Checked sites that now have an active R01+."), + ("R00 Billed", str(len(billed_rows)), "Checked sites where the new-book unit is already billed."), + ("R00 Unbilled", str(len(unbilled_rows)), "Checked sites where R00 is done but not billed yet."), + ] + + card_rows = [] + for label, value, subtext in cards: + card_rows.append( + Table( + [ + [Paragraph(label.upper(), styles["card_label"])], + [Paragraph(value, styles["card_value"])], + [Paragraph(subtext, styles["card_sub"])], + ], + colWidths=[3.0 * inch], + style=TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, -1), colors.white), + ("BOX", (0, 0), (-1, -1), 0.8, LINE), + ("INNERPADDING", (0, 0), (-1, -1), 10), + ("ROUNDEDCORNERS", [10, 10, 10, 10]), + ] + ), + ) + ) + return Table([card_rows], colWidths=[3.1 * inch] * 4, hAlign="LEFT") + + +def build_table(rows, title, intro): + flow = [Paragraph(title, styles["section_title"]), Paragraph(intro, styles["small"]), Spacer(1, 10)] + + header = [ + text_para("Site", "th"), + text_para("DB Title", "th"), + text_para("Internal #", "th"), + text_para("R00 Done", "th"), + text_para("R00 Billed", "th"), + text_para("Active R#", "th"), + text_para("Active Status", "th"), + text_para("Completed Revisions", "th"), + text_para("Billed Versions", "th"), + text_para("Version Summary", "th"), + ] + data = [header] + + for row in rows: + data.append( + [ + text_para(row.get("site", ""), "cell_mono"), + text_para(row.get("dbTitle", "")), + text_para(row.get("internalTaskNumber", ""), "cell_mono"), + status_pill(row.get("r00Completed", "No")), + status_pill(row.get("r00Billed", "No")), + text_para(row.get("activeRevision", "—"), "cell_mono"), + status_pill(row.get("activeStatus", "—")), + text_para(row.get("completedRevisions", "—")), + text_para(row.get("billedVersions", "—")), + text_para(row.get("versionSummary", "—")), + ] + ) + + table = LongTable( + data, + colWidths=[ + 0.78 * inch, + 1.7 * inch, + 0.72 * inch, + 0.75 * inch, + 0.85 * inch, + 0.6 * inch, + 1.0 * inch, + 1.45 * inch, + 1.2 * inch, + 2.15 * inch, + ], + repeatRows=1, + ) + table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, 0), colors.white), + ("BOX", (0, 0), (-1, -1), 0.8, LINE), + ("INNERGRID", (0, 0), (-1, -1), 0.4, LINE), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("TOPPADDING", (0, 0), (-1, -1), 6), + ("BOTTOMPADDING", (0, 0), (-1, -1), 6), + ("LEFTPADDING", (0, 0), (-1, -1), 6), + ("RIGHTPADDING", (0, 0), (-1, -1), 6), + ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#FBF8F2")]), + ] + ) + ) + flow.append(table) + return flow + + +styles = getSampleStyleSheet() +styles.add( + ParagraphStyle( + name="title_main", + fontName="Helvetica-Bold", + fontSize=24, + leading=28, + textColor=TEXT, + alignment=TA_LEFT, + ) +) +styles.add( + ParagraphStyle( + name="eyebrow", + fontName="Helvetica-Bold", + fontSize=9, + leading=11, + textColor=MUTED, + ) +) +styles.add( + ParagraphStyle( + name="subtitle", + fontName="Helvetica", + fontSize=11, + leading=15, + textColor=MUTED, + ) +) +styles.add( + ParagraphStyle( + name="section_title", + fontName="Helvetica-Bold", + fontSize=15, + leading=18, + textColor=TEXT, + spaceAfter=2, + ) +) +styles.add( + ParagraphStyle( + name="small", + fontName="Helvetica", + fontSize=9, + leading=12, + textColor=MUTED, + ) +) +styles.add( + ParagraphStyle( + name="card_label", + fontName="Helvetica-Bold", + fontSize=8, + leading=10, + textColor=MUTED, + ) +) +styles.add( + ParagraphStyle( + name="card_value", + fontName="Helvetica-Bold", + fontSize=24, + leading=26, + textColor=TEXT, + ) +) +styles.add( + ParagraphStyle( + name="card_sub", + fontName="Helvetica", + fontSize=8.5, + leading=11, + textColor=MUTED, + ) +) +styles.add( + ParagraphStyle( + name="th", + fontName="Helvetica-Bold", + fontSize=8, + leading=10, + textColor=MUTED, + ) +) +styles.add( + ParagraphStyle( + name="cell", + fontName="Helvetica", + fontSize=8.2, + leading=10.5, + textColor=TEXT, + ) +) +styles.add( + ParagraphStyle( + name="cell_mono", + fontName="Courier", + fontSize=8.1, + leading=10.3, + textColor=TEXT, + ) +) +styles.add( + ParagraphStyle( + name="pill", + fontName="Helvetica-Bold", + fontSize=7.5, + leading=9, + alignment=1, + ) +) + + +def main(): + rows = json.loads(INPUT_PATH.read_text()) + existing_rows = [row for row in rows if row.get("exists", True) is not False] + active_rows = [row for row in existing_rows if row.get("hasActiveRevision") == "Yes"] + no_active_rows = [row for row in existing_rows if row.get("hasActiveRevision") != "Yes"] + + doc = BaseDocTemplate( + str(OUTPUT_PATH), + pagesize=landscape(legal), + leftMargin=36, + rightMargin=36, + topMargin=28, + bottomMargin=28, + ) + frame = Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height, id="main") + doc.addPageTemplates([PageTemplate(id="default", frames=[frame], onPage=page_template)]) + + story = [ + Paragraph("FOURGE PORTAL AUDIT", styles["eyebrow"]), + Spacer(1, 4), + Paragraph("Checked Sites Revision Audit", styles["title_main"]), + Spacer(1, 6), + Paragraph( + "This report treats your checkmark as “R00 was completed,” then separates that from billing and from any newer active revisions. " + "It is designed to make client-billing comparison easier at a glance.", + styles["subtitle"], + ), + Spacer(1, 18), + build_summary_cards(rows), + Spacer(1, 18), + ] + + story.extend( + build_table( + active_rows, + "Checked Sites With Active Revisions", + "These are the sites where R00 is done, but there is still a newer revision active or awaiting billing.", + ) + ) + story.append(PageBreak()) + story.extend( + build_table( + no_active_rows, + "Checked Sites With No Active Revision", + "These are the checked sites that currently do not have a newer active revision.", + ) + ) + + doc.build(story) + print(OUTPUT_PATH) + + +if __name__ == "__main__": + main() diff --git a/docs/generate-finance-pdf.mjs b/docs/generate-finance-pdf.mjs new file mode 100644 index 0000000..c09f667 --- /dev/null +++ b/docs/generate-finance-pdf.mjs @@ -0,0 +1,301 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +const { PDFDocument, StandardFonts, rgb } = require('pdf-lib'); + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const outputPath = path.join(__dirname, 'finance-summary.pdf'); + +const pdf = await PDFDocument.create(); +const fontRegular = await pdf.embedFont(StandardFonts.Helvetica); +const fontBold = await pdf.embedFont(StandardFonts.HelveticaBold); + +const PAGE_W = 612; +const PAGE_H = 792; +const MARGIN = 42; +const ACCENT = rgb(0.96, 0.65, 0.14); +const TEXT = rgb(0.09, 0.09, 0.09); +const MUTED = rgb(0.36, 0.36, 0.36); +const BORDER = rgb(0.90, 0.86, 0.80); +const SURFACE = rgb(0.985, 0.97, 0.94); +const WHITE = rgb(1, 1, 1); + +let page = pdf.addPage([PAGE_W, PAGE_H]); +let y = PAGE_H - MARGIN; + +function ensureSpace(heightNeeded = 40) { + if (y - heightNeeded < MARGIN) { + page = pdf.addPage([PAGE_W, PAGE_H]); + y = PAGE_H - MARGIN; + } +} + +function drawText(text, x, yy, size = 12, font = fontRegular, color = TEXT) { + page.drawText(text, { x, y: yy, size, font, color }); +} + +function wrapText(text, maxWidth, size = 12, font = fontRegular) { + const words = String(text).split(/\s+/); + const lines = []; + let line = ''; + for (const word of words) { + const test = line ? `${line} ${word}` : word; + const width = font.widthOfTextAtSize(test, size); + if (width <= maxWidth || !line) line = test; + else { + lines.push(line); + line = word; + } + } + if (line) lines.push(line); + return lines; +} + +function drawParagraph(text, x, maxWidth, size = 12, color = TEXT, lineGap = 4, font = fontRegular) { + const lines = wrapText(text, maxWidth, size, font); + const lineHeight = size + lineGap; + ensureSpace(lines.length * lineHeight + 4); + for (const line of lines) { + drawText(line, x, y, size, font, color); + y -= lineHeight; + } + y -= 4; +} + +function drawSectionTitle(text) { + ensureSpace(30); + drawText(text, MARGIN, y, 18, fontBold, TEXT); + y -= 26; +} + +function drawCard(x, yy, w, h, title, bodyLines) { + page.drawRectangle({ x, y: yy - h, width: w, height: h, color: SURFACE, borderColor: BORDER, borderWidth: 1, borderRadius: 12 }); + drawText(title, x + 12, yy - 20, 11, fontBold, MUTED); + let innerY = yy - 40; + for (const line of bodyLines) { + const wrapped = wrapText(line, w - 24, 11, fontRegular); + for (const piece of wrapped) { + drawText(piece, x + 12, innerY, 11, fontRegular, TEXT); + innerY -= 15; + } + innerY -= 2; + } +} + +function drawFlowBox(title, lines) { + const contentLines = lines.flatMap(line => wrapText(line, PAGE_W - MARGIN * 2 - 24, 11, fontRegular)); + const h = 40 + contentLines.length * 15 + 10; + ensureSpace(h + 24); + page.drawRectangle({ x: MARGIN, y: y - h, width: PAGE_W - MARGIN * 2, height: h, color: WHITE, borderColor: BORDER, borderWidth: 1, borderRadius: 12 }); + drawText(title, MARGIN + 14, y - 20, 11, fontBold, MUTED); + let innerY = y - 40; + for (const line of contentLines) { + drawText(line, MARGIN + 14, innerY, 11, fontRegular, TEXT); + innerY -= 15; + } + y -= h + 18; + if (y > MARGIN + 40) { + drawText("v", PAGE_W / 2 - 3, y + 3, 14, fontBold, ACCENT); + } +} + +function drawBullet(text) { + const bulletX = MARGIN + 4; + const textX = MARGIN + 18; + const maxWidth = PAGE_W - textX - MARGIN; + const lines = wrapText(text, maxWidth, 11, fontRegular); + const lineHeight = 15; + ensureSpace(lines.length * lineHeight + 4); + drawText("•", bulletX, y, 12, fontBold, TEXT); + for (const line of lines) { + drawText(line, textX, y, 11, fontRegular, TEXT); + y -= lineHeight; + } + y -= 2; +} + +function drawTable(headers, rows, colWidths) { + const tableW = colWidths.reduce((a, b) => a + b, 0); + const startX = MARGIN; + const rowPad = 8; + const headerH = 24; + ensureSpace(36); + page.drawRectangle({ x: startX, y: y - headerH, width: tableW, height: headerH, color: SURFACE, borderColor: BORDER, borderWidth: 1 }); + let x = startX; + headers.forEach((head, i) => { + drawText(head, x + 6, y - 15, 10, fontBold, MUTED); + x += colWidths[i]; + }); + y -= headerH; + for (const row of rows) { + const wrappedCells = row.map((cell, i) => wrapText(cell, colWidths[i] - 12, 10, fontRegular)); + const lines = Math.max(...wrappedCells.map(c => c.length)); + const rowH = Math.max(22, lines * 13 + rowPad); + ensureSpace(rowH + 2); + page.drawRectangle({ x: startX, y: y - rowH, width: tableW, height: rowH, borderColor: BORDER, borderWidth: 1 }); + let cellX = startX; + wrappedCells.forEach((cellLines, i) => { + let cellY = y - 14; + for (const line of cellLines) { + drawText(line, cellX + 6, cellY, 10, fontRegular, TEXT); + cellY -= 12; + } + cellX += colWidths[i]; + }); + y -= rowH; + } + y -= 10; +} + +function newPage() { + page = pdf.addPage([PAGE_W, PAGE_H]); + y = PAGE_H - MARGIN; +} + +// Page 1 +page.drawRectangle({ + x: MARGIN, + y: y - 88, + width: PAGE_W - MARGIN * 2, + height: 88, + color: SURFACE, + borderColor: BORDER, + borderWidth: 1.2, + borderRadius: 16, +}); +drawText('Fourge Portal Finance Summary', MARGIN + 18, y - 30, 24, fontBold, TEXT); +drawText('Role cheat sheet, finance lanes, and data flow for team, client, and subcontractor users.', MARGIN + 18, y - 54, 12, fontRegular, MUTED); +y -= 112; + +drawSectionTitle('1. Executive Summary'); +drawParagraph('The finance area is split into three separate role lanes. Team gets the full finance hub, clients get invoice viewing only, and subcontractors get invoice submission plus purchase-order review.', MARGIN, PAGE_W - MARGIN * 2, 12, TEXT); + +ensureSpace(140); +const gap = 12; +const cardW = (PAGE_W - MARGIN * 2 - gap * 2) / 3; +drawCard(MARGIN, y, cardW, 104, 'TEAM', [ + 'Main route: /invoices', + 'Handles client invoices, expenses, subcontractor invoices, and POs.', +]); +drawCard(MARGIN + cardW + gap, y, cardW, 104, 'CLIENT', [ + 'Main route: /my-invoices', + 'Can view invoices, filter by company, and download PDFs.', +]); +drawCard(MARGIN + (cardW + gap) * 2, y, cardW, 104, 'SUBCONTRACTOR', [ + 'Main routes: /my-invoices-sub and /my-purchase-orders', + 'Can submit invoices to Fourge and approve POs.', +]); +y -= 126; + +drawSectionTitle('2. Role-by-Role Cheat Sheet'); +drawTable( + ['ROLE', 'MAIN ROUTES', 'CAN DO', 'CANNOT DO'], + [ + ['Team', '/invoices', 'Client invoices, expenses, POs, subcontractor invoices, paid status, PDFs, email sends', 'No major finance restrictions in UI'], + ['Client', '/my-invoices', 'View invoices, filter by company, download invoice PDFs', 'No expenses, no edits, no POs, no subcontractor finance tools'], + ['Subcontractor', '/my-invoices-sub /my-purchase-orders', 'Submit invoices, view status, download receipt after paid, approve POs', 'No team finance overview, no client billing control'], + ], + [72, 120, 205, 131], +); + +drawSectionTitle('3. Core Data Models'); +drawBullet('invoices + invoice_items: Fourge bills clients.'); +drawBullet('expenses: Fourge internal finance records.'); +drawBullet('subcontractor_invoices + subcontractor_invoice_items: subcontractors bill Fourge.'); +drawBullet('subcontractor_payments + subcontractor_po_items: Fourge sends purchase orders to subcontractors.'); +drawBullet('tasks and submissions connect work records to invoice line items.'); + +// Page 2 +newPage(); +drawSectionTitle('4. System Flow'); +drawFlowBox('CLIENT BILLING FLOW', [ + 'Approved work reaches tasks.status = client_approved.', + 'Team finance loads uninvoiced tasks and revisions and builds invoice line items.', + 'Invoice is saved, PDF is generated, and email is sent to the client.', + 'Later team marks the invoice paid.', +]); +drawFlowBox('EXPENSE FLOW', [ + 'Team logs internal expenses and can attach receipt files.', + 'Expenses feed overview charts, yearly finance summaries, and profit calculations.', +]); +drawFlowBox('SUBCONTRACTOR INVOICE FLOW', [ + 'Subcontractor creates an invoice from completed approved tasks assigned to them.', + 'Fourge reviews the invoice, marks it paid, and can send a receipt PDF back.', +]); +drawFlowBox('PURCHASE ORDER FLOW', [ + 'Team creates a PO for a subcontractor, optionally linked to project tasks.', + 'Subcontractor reviews and approves the PO from their own portal view.', +]); + +y -= 8; +drawSectionTitle('5. Status Logic'); +drawBullet('Client invoice: draft -> sent -> paid'); +drawBullet('Subcontractor invoice: draft -> submitted -> paid'); +drawBullet('Purchase order: draft -> sent -> approved -> ready_to_pay -> paid'); +drawBullet('Cancelled is the exit state for a PO.'); + +drawSectionTitle('6. Key Business Rules'); +drawBullet('Client invoices only pull approved, uninvoiced work.'); +drawBullet('Tasks and revisions are marked invoiced after invoice creation.'); +drawBullet('Deleting a team invoice rolls linked invoice flags back.'); +drawBullet('Team invoice status changes also push task status changes.'); +drawBullet('Revision billing rule: R00 = new work, R01 = free, R02+ = billable increments.'); +drawBullet('Subcontractor invoices are created by the external user and then reviewed/paid by team.'); +drawBullet('POs are created by team and approved by the subcontractor.'); + +// Page 3 +newPage(); +drawSectionTitle('7. Database Relationship Chart'); +drawParagraph('Simple relationship map:', MARGIN, PAGE_W - MARGIN * 2, 12, TEXT, 4, fontBold); +const diagram = [ + 'COMPANIES', + ' -> invoices', + ' -> invoice_items', + ' -> task_id -> tasks', + ' -> submission_id -> submissions', + '', + 'PROJECTS', + ' -> tasks', + ' -> submissions', + '', + 'PROFILES (external)', + ' -> subcontractor_invoices', + ' -> subcontractor_invoice_items', + ' -> task_id -> tasks', + ' -> subcontractor_payments (POs)', + ' -> subcontractor_po_items', + ' -> task_id -> tasks', + '', + 'EXPENSES', + ' -> standalone internal finance records', +]; +page.drawRectangle({ x: MARGIN, y: y - 270, width: PAGE_W - MARGIN * 2, height: 270, color: SURFACE, borderColor: BORDER, borderWidth: 1, borderRadius: 12 }); +let codeY = y - 20; +for (const line of diagram) { + drawText(line, MARGIN + 14, codeY, 11, fontRegular, TEXT); + codeY -= 14; +} +y -= 292; + +drawSectionTitle('8. Quick Route Map'); +drawTable( + ['SURFACE', 'ROUTE', 'USER'], + [ + ['Finance Hub', '/invoices', 'Team'], + ['Client Invoices', '/my-invoices', 'Client'], + ['Subcontractor Invoices', '/my-invoices-sub', 'External'], + ['Create Subcontractor Invoice', '/my-invoices-sub/new', 'External'], + ['Purchase Orders', '/my-purchase-orders', 'External'], + ], + [210, 230, 88], +); + +drawParagraph('Generated from the current portal codebase summary on June 3, 2026.', MARGIN, PAGE_W - MARGIN * 2, 10, MUTED); + +const bytes = await pdf.save(); +await fs.writeFile(outputPath, bytes); +console.log(outputPath); diff --git a/docs/invoice-match-audit-2026-06-05.json b/docs/invoice-match-audit-2026-06-05.json new file mode 100644 index 0000000..84def3f --- /dev/null +++ b/docs/invoice-match-audit-2026-06-05.json @@ -0,0 +1,1376 @@ +{ + "checkedCount": 26, + "foundCount": 39, + "missingFromDb": [ + "00278", + "00279", + "00281", + "00282", + "00284", + "00290", + "00291", + "30051", + "30054", + "30056", + "30058", + "30061", + "30063", + "30068", + "30095", + "30098", + "30210", + "30262", + "30319", + "30342", + "00288", + "30344", + "30345", + "30348", + "30349", + "30350", + "30355", + "30357", + "30407", + "68175", + "30412", + "30413", + "68215", + "68216", + "30437", + "68220", + "68432", + "68516", + "68617", + "68620", + "68631", + "68636", + "68637", + "68638", + "68715", + "68723", + "68751", + "68776", + "68777", + "68778", + "68779", + "68780", + "68781", + "68782", + "68784", + "68785" + ], + "checkedNotBilledR00": [ + { + "site": "30094", + "checked": true, + "exists": true, + "title": "30094 Colorado Springs, CO", + "taskNumber": 35, + "status": "client_approved", + "currentVersion": "R00", + "billedVersions": [], + "completedUnbilledVersions": [ + "R00" + ], + "activeRevision": "—", + "activeStatus": "—", + "r00Billed": false + }, + { + "site": "30261", + "checked": true, + "exists": true, + "title": "30261 NSA Clovis NM", + "taskNumber": 70, + "status": "client_approved", + "currentVersion": "R00", + "billedVersions": [], + "completedUnbilledVersions": [ + "R00" + ], + "activeRevision": "—", + "activeStatus": "—", + "r00Billed": false + }, + { + "site": "68720", + "checked": true, + "exists": true, + "title": "68720 NSA Lancaster PA", + "taskNumber": 74, + "status": "client_approved", + "currentVersion": "R00", + "billedVersions": [], + "completedUnbilledVersions": [ + "R00" + ], + "activeRevision": "—", + "activeStatus": "—", + "r00Billed": false + } + ], + "uncheckedButBilled": [], + "checkedWithCompletedUnbilled": [ + { + "site": "30094", + "checked": true, + "exists": true, + "title": "30094 Colorado Springs, CO", + "taskNumber": 35, + "status": "client_approved", + "currentVersion": "R00", + "billedVersions": [], + "completedUnbilledVersions": [ + "R00" + ], + "activeRevision": "—", + "activeStatus": "—", + "r00Billed": false + }, + { + "site": "30096", + "checked": true, + "exists": true, + "title": "30096 Eerie, CO", + "taskNumber": 36, + "status": "not_started", + "currentVersion": "R02", + "billedVersions": [ + "R00: INV-2026-006 (sent)" + ], + "completedUnbilledVersions": [ + "R01" + ], + "activeRevision": "R02", + "activeStatus": "not_started", + "r00Billed": true + }, + { + "site": "30261", + "checked": true, + "exists": true, + "title": "30261 NSA Clovis NM", + "taskNumber": 70, + "status": "client_approved", + "currentVersion": "R00", + "billedVersions": [], + "completedUnbilledVersions": [ + "R00" + ], + "activeRevision": "—", + "activeStatus": "—", + "r00Billed": false + }, + { + "site": "68720", + "checked": true, + "exists": true, + "title": "68720 NSA Lancaster PA", + "taskNumber": 74, + "status": "client_approved", + "currentVersion": "R00", + "billedVersions": [], + "completedUnbilledVersions": [ + "R00" + ], + "activeRevision": "—", + "activeStatus": "—", + "r00Billed": false + }, + { + "site": "68721", + "checked": true, + "exists": true, + "title": "68721 NSA Mechanicsburg PA", + "taskNumber": 48, + "status": "client_approved", + "currentVersion": "R02", + "billedVersions": [ + "R00: INV-2026-006 (sent)", + "R01: INV-2026-006 (sent)" + ], + "completedUnbilledVersions": [ + "R02" + ], + "activeRevision": "R02", + "activeStatus": "client_approved", + "r00Billed": true + }, + { + "site": "68811", + "checked": true, + "exists": true, + "title": "68811 Kelso, WA", + "taskNumber": 52, + "status": "not_started", + "currentVersion": "R02", + "billedVersions": [ + "R00: INV-2026-006 (sent)" + ], + "completedUnbilledVersions": [ + "R01" + ], + "activeRevision": "R02", + "activeStatus": "not_started", + "r00Billed": true + } + ], + "checkedWithActiveRevision": [ + { + "site": "00286", + "checked": true, + "exists": true, + "title": "00286 Riverside, CA", + "taskNumber": 31, + "status": "not_started", + "currentVersion": "R01", + "billedVersions": [ + "R00: INV-2026-006 (sent)" + ], + "completedUnbilledVersions": [], + "activeRevision": "R01", + "activeStatus": "not_started", + "r00Billed": true + }, + { + "site": "30055", + "checked": true, + "exists": true, + "title": "30055 Moreno Valley, CA", + "taskNumber": 33, + "status": "client_review", + "currentVersion": "R01", + "billedVersions": [ + "R00: INV-2026-006 (sent)" + ], + "completedUnbilledVersions": [], + "activeRevision": "R01", + "activeStatus": "client_review", + "r00Billed": true + }, + { + "site": "30096", + "checked": true, + "exists": true, + "title": "30096 Eerie, CO", + "taskNumber": 36, + "status": "not_started", + "currentVersion": "R02", + "billedVersions": [ + "R00: INV-2026-006 (sent)" + ], + "completedUnbilledVersions": [ + "R01" + ], + "activeRevision": "R02", + "activeStatus": "not_started", + "r00Billed": true + }, + { + "site": "68639", + "checked": true, + "exists": true, + "title": "68639 NSA Los Lunas NM", + "taskNumber": 40, + "status": "client_review", + "currentVersion": "R01", + "billedVersions": [ + "R00: INV-2026-006 (sent)" + ], + "completedUnbilledVersions": [], + "activeRevision": "R01", + "activeStatus": "client_review", + "r00Billed": true + }, + { + "site": "68663", + "checked": true, + "exists": true, + "title": "68663 NSA Oklahoma City OK", + "taskNumber": 41, + "status": "not_started", + "currentVersion": "R01", + "billedVersions": [ + "R00: INV-2026-006 (sent)" + ], + "completedUnbilledVersions": [], + "activeRevision": "R01", + "activeStatus": "not_started", + "r00Billed": true + }, + { + "site": "68664", + "checked": true, + "exists": true, + "title": "68664 NSA Oklahoma City OK", + "taskNumber": 42, + "status": "not_started", + "currentVersion": "R01", + "billedVersions": [ + "R00: INV-2026-006 (sent)" + ], + "completedUnbilledVersions": [], + "activeRevision": "R01", + "activeStatus": "not_started", + "r00Billed": true + }, + { + "site": "68666", + "checked": true, + "exists": true, + "title": "68666 NSA Oklahoma City, OK", + "taskNumber": 43, + "status": "not_started", + "currentVersion": "R01", + "billedVersions": [ + "R00: INV-2026-006 (sent)" + ], + "completedUnbilledVersions": [], + "activeRevision": "R01", + "activeStatus": "not_started", + "r00Billed": true + }, + { + "site": "68669", + "checked": true, + "exists": true, + "title": "68669 NSA Oklahoma City OK", + "taskNumber": 44, + "status": "not_started", + "currentVersion": "R01", + "billedVersions": [ + "R00: INV-2026-006 (sent)" + ], + "completedUnbilledVersions": [], + "activeRevision": "R01", + "activeStatus": "not_started", + "r00Billed": true + }, + { + "site": "68670", + "checked": true, + "exists": true, + "title": "68670 NSA Oklahoma City OK", + "taskNumber": 45, + "status": "client_review", + "currentVersion": "R02", + "billedVersions": [ + "R00: INV-2026-006 (sent)", + "R01: INV-2026-006 (sent)" + ], + "completedUnbilledVersions": [], + "activeRevision": "R02", + "activeStatus": "client_review", + "r00Billed": true + }, + { + "site": "68671", + "checked": true, + "exists": true, + "title": "68671 NSA Oklahoma City OK", + "taskNumber": 46, + "status": "client_review", + "currentVersion": "R01", + "billedVersions": [ + "R00: INV-2026-006 (sent)" + ], + "completedUnbilledVersions": [], + "activeRevision": "R01", + "activeStatus": "client_review", + "r00Billed": true + }, + { + "site": "68673", + "checked": true, + "exists": true, + "title": "68673 NSA Oklahoma City OK", + "taskNumber": 47, + "status": "client_review", + "currentVersion": "R01", + "billedVersions": [ + "R00: INV-2026-006 (sent)" + ], + "completedUnbilledVersions": [], + "activeRevision": "R01", + "activeStatus": "client_review", + "r00Billed": true + }, + { + "site": "68721", + "checked": true, + "exists": true, + "title": "68721 NSA Mechanicsburg PA", + "taskNumber": 48, + "status": "client_approved", + "currentVersion": "R02", + "billedVersions": [ + "R00: INV-2026-006 (sent)", + "R01: INV-2026-006 (sent)" + ], + "completedUnbilledVersions": [ + "R02" + ], + "activeRevision": "R02", + "activeStatus": "client_approved", + "r00Billed": true + }, + { + "site": "68808", + "checked": true, + "exists": true, + "title": "68808 Camas, WA", + "taskNumber": 49, + "status": "not_started", + "currentVersion": "R01", + "billedVersions": [ + "R00: INV-2026-006 (sent)" + ], + "completedUnbilledVersions": [], + "activeRevision": "R01", + "activeStatus": "not_started", + "r00Billed": true + }, + { + "site": "68809", + "checked": true, + "exists": true, + "title": "68809 Centralia, WA", + "taskNumber": 50, + "status": "not_started", + "currentVersion": "R02", + "billedVersions": [ + "R00: INV-2026-006 (sent)", + "R01: INV-2026-006 (sent)" + ], + "completedUnbilledVersions": [], + "activeRevision": "R02", + "activeStatus": "not_started", + "r00Billed": true + }, + { + "site": "68810", + "checked": true, + "exists": true, + "title": "68810 Chehalis, WA", + "taskNumber": 51, + "status": "not_started", + "currentVersion": "R02", + "billedVersions": [ + "R00: INV-2026-006 (sent)", + "R01: INV-2026-006 (sent)" + ], + "completedUnbilledVersions": [], + "activeRevision": "R02", + "activeStatus": "not_started", + "r00Billed": true + }, + { + "site": "68811", + "checked": true, + "exists": true, + "title": "68811 Kelso, WA", + "taskNumber": 52, + "status": "not_started", + "currentVersion": "R02", + "billedVersions": [ + "R00: INV-2026-006 (sent)" + ], + "completedUnbilledVersions": [ + "R01" + ], + "activeRevision": "R02", + "activeStatus": "not_started", + "r00Billed": true + } + ], + "result": [ + { + "site": "00278", + "checked": false, + "exists": false + }, + { + "site": "00279", + "checked": false, + "exists": false + }, + { + "site": "00281", + "checked": false, + "exists": false + }, + { + "site": "00282", + "checked": false, + "exists": false + }, + { + "site": "00284", + "checked": false, + "exists": false + }, + { + "site": "00285", + "checked": false, + "exists": true, + "title": "00285 NSA Moreno Valley CA", + "taskNumber": 65, + "status": "not_started", + "currentVersion": "R00", + "billedVersions": [], + "completedUnbilledVersions": [], + "activeRevision": "—", + "activeStatus": "—", + "r00Billed": false + }, + { + "site": "00286", + "checked": true, + "exists": true, + "title": "00286 Riverside, CA", + "taskNumber": 31, + "status": "not_started", + "currentVersion": "R01", + "billedVersions": [ + "R00: INV-2026-006 (sent)" + ], + "completedUnbilledVersions": [], + "activeRevision": "R01", + "activeStatus": "not_started", + "r00Billed": true + }, + { + "site": "00290", + "checked": false, + "exists": false + }, + { + "site": "00291", + "checked": false, + "exists": false + }, + { + "site": "30049", + "checked": false, + "exists": true, + "title": "30049 Elk Grove, CA", + "taskNumber": 32, + "status": "not_started", + "currentVersion": "R00", + "billedVersions": [], + "completedUnbilledVersions": [], + "activeRevision": "—", + "activeStatus": "—", + "r00Billed": false + }, + { + "site": "30051", + "checked": false, + "exists": false + }, + { + "site": "30054", + "checked": false, + "exists": false + }, + { + "site": "30055", + "checked": true, + "exists": true, + "title": "30055 Moreno Valley, CA", + "taskNumber": 33, + "status": "client_review", + "currentVersion": "R01", + "billedVersions": [ + "R00: INV-2026-006 (sent)" + ], + "completedUnbilledVersions": [], + "activeRevision": "R01", + "activeStatus": "client_review", + "r00Billed": true + }, + { + "site": "30056", + "checked": false, + "exists": false + }, + { + "site": "30058", + "checked": false, + "exists": false + }, + { + "site": "30060", + "checked": false, + "exists": true, + "title": "30060 NSA Palmdale CA", + "taskNumber": 66, + "status": "not_started", + "currentVersion": "R00", + "billedVersions": [], + "completedUnbilledVersions": [], + "activeRevision": "—", + "activeStatus": "—", + "r00Billed": false + }, + { + "site": "30061", + "checked": false, + "exists": false + }, + { + "site": "30062", + "checked": true, + "exists": true, + "title": "30062 Riverside, CA", + "taskNumber": 34, + "status": "invoiced", + "currentVersion": "R00", + "billedVersions": [ + "R00: INV-2026-006 (sent)" + ], + "completedUnbilledVersions": [], + "activeRevision": "—", + "activeStatus": "—", + "r00Billed": true + }, + { + "site": "30063", + "checked": false, + "exists": false + }, + { + "site": "30068", + "checked": false, + "exists": false + }, + { + "site": "30091", + "checked": false, + "exists": true, + "title": "30091 NSA Colorado Springs CO", + "taskNumber": 67, + "status": "not_started", + "currentVersion": "R00", + "billedVersions": [], + "completedUnbilledVersions": [], + "activeRevision": "—", + "activeStatus": "—", + "r00Billed": false + }, + { + "site": "30094", + "checked": true, + "exists": true, + "title": "30094 Colorado Springs, CO", + "taskNumber": 35, + "status": "client_approved", + "currentVersion": "R00", + "billedVersions": [], + "completedUnbilledVersions": [ + "R00" + ], + "activeRevision": "—", + "activeStatus": "—", + "r00Billed": false + }, + { + "site": "30095", + "checked": false, + "exists": false + }, + { + "site": "30096", + "checked": true, + "exists": true, + "title": "30096 Eerie, CO", + "taskNumber": 36, + "status": "not_started", + "currentVersion": "R02", + "billedVersions": [ + "R00: INV-2026-006 (sent)" + ], + "completedUnbilledVersions": [ + "R01" + ], + "activeRevision": "R02", + "activeStatus": "not_started", + "r00Billed": true + }, + { + "site": "30098", + "checked": false, + "exists": false + }, + { + "site": "30101", + "checked": false, + "exists": true, + "title": "30101 Monument, CO", + "taskNumber": 37, + "status": "on_hold", + "currentVersion": "R00", + "billedVersions": [], + "completedUnbilledVersions": [], + "activeRevision": "—", + "activeStatus": "—", + "r00Billed": false + }, + { + "site": "30210", + "checked": false, + "exists": false + }, + { + "site": "30211", + "checked": false, + "exists": true, + "title": "30211 NSA Post Falls ID", + "taskNumber": 68, + "status": "not_started", + "currentVersion": "R00", + "billedVersions": [], + "completedUnbilledVersions": [], + "activeRevision": "—", + "activeStatus": "—", + "r00Billed": false + }, + { + "site": "30212", + "checked": false, + "exists": true, + "title": "30212 NSA Sandpoint ID", + "taskNumber": 69, + "status": "not_started", + "currentVersion": "R00", + "billedVersions": [], + "completedUnbilledVersions": [], + "activeRevision": "—", + "activeStatus": "—", + "r00Billed": false + }, + { + "site": "30258", + "checked": true, + "exists": true, + "title": "30258 Lebanon, NH", + "taskNumber": 38, + "status": "invoiced", + "currentVersion": "R00", + "billedVersions": [ + "R00: INV-2026-006 (sent)" + ], + "completedUnbilledVersions": [], + "activeRevision": "—", + "activeStatus": "—", + "r00Billed": true + }, + { + "site": "30259", + "checked": true, + "exists": true, + "title": "30259 Hamburg, NJ", + "taskNumber": 39, + "status": "invoiced", + "currentVersion": "R00", + "billedVersions": [ + "R00: INV-2026-006 (sent)" + ], + "completedUnbilledVersions": [], + "activeRevision": "—", + "activeStatus": "—", + "r00Billed": true + }, + { + "site": "30261", + "checked": true, + "exists": true, + "title": "30261 NSA Clovis NM", + "taskNumber": 70, + "status": "client_approved", + "currentVersion": "R00", + "billedVersions": [], + "completedUnbilledVersions": [ + "R00" + ], + "activeRevision": "—", + "activeStatus": "—", + "r00Billed": false + }, + { + "site": "30262", + "checked": false, + "exists": false + }, + { + "site": "30319", + "checked": false, + "exists": false + }, + { + "site": "30342", + "checked": false, + "exists": false + }, + { + "site": "00288", + "checked": false, + "exists": false + }, + { + "site": "30344", + "checked": false, + "exists": false + }, + { + "site": "30345", + "checked": false, + "exists": false + }, + { + "site": "30348", + "checked": false, + "exists": false + }, + { + "site": "30349", + "checked": false, + "exists": false + }, + { + "site": "30350", + "checked": false, + "exists": false + }, + { + "site": "30355", + "checked": false, + "exists": false + }, + { + "site": "30357", + "checked": false, + "exists": false + }, + { + "site": "30407", + "checked": false, + "exists": false + }, + { + "site": "68175", + "checked": false, + "exists": false + }, + { + "site": "30412", + "checked": false, + "exists": false + }, + { + "site": "30413", + "checked": false, + "exists": false + }, + { + "site": "68215", + "checked": false, + "exists": false + }, + { + "site": "68216", + "checked": false, + "exists": false + }, + { + "site": "30437", + "checked": false, + "exists": false + }, + { + "site": "68220", + "checked": false, + "exists": false + }, + { + "site": "68432", + "checked": false, + "exists": false + }, + { + "site": "68452", + "checked": false, + "exists": true, + "title": "68452 NSA Houston TX", + "taskNumber": 71, + "status": "not_started", + "currentVersion": "R00", + "billedVersions": [], + "completedUnbilledVersions": [], + "activeRevision": "—", + "activeStatus": "—", + "r00Billed": false + }, + { + "site": "68453", + "checked": false, + "exists": true, + "title": "68453 NSA Katy TX", + "taskNumber": 72, + "status": "not_started", + "currentVersion": "R00", + "billedVersions": [], + "completedUnbilledVersions": [], + "activeRevision": "—", + "activeStatus": "—", + "r00Billed": false + }, + { + "site": "68454", + "checked": false, + "exists": true, + "title": "68454 NSA Katy TX", + "taskNumber": 73, + "status": "not_started", + "currentVersion": "R00", + "billedVersions": [], + "completedUnbilledVersions": [], + "activeRevision": "—", + "activeStatus": "—", + "r00Billed": false + }, + { + "site": "68516", + "checked": false, + "exists": false + }, + { + "site": "68617", + "checked": false, + "exists": false + }, + { + "site": "68620", + "checked": false, + "exists": false + }, + { + "site": "68631", + "checked": false, + "exists": false + }, + { + "site": "68636", + "checked": false, + "exists": false + }, + { + "site": "68637", + "checked": false, + "exists": false + }, + { + "site": "68638", + "checked": false, + "exists": false + }, + { + "site": "68639", + "checked": true, + "exists": true, + "title": "68639 NSA Los Lunas NM", + "taskNumber": 40, + "status": "client_review", + "currentVersion": "R01", + "billedVersions": [ + "R00: INV-2026-006 (sent)" + ], + "completedUnbilledVersions": [], + "activeRevision": "R01", + "activeStatus": "client_review", + "r00Billed": true + }, + { + "site": "68663", + "checked": true, + "exists": true, + "title": "68663 NSA Oklahoma City OK", + "taskNumber": 41, + "status": "not_started", + "currentVersion": "R01", + "billedVersions": [ + "R00: INV-2026-006 (sent)" + ], + "completedUnbilledVersions": [], + "activeRevision": "R01", + "activeStatus": "not_started", + "r00Billed": true + }, + { + "site": "68664", + "checked": true, + "exists": true, + "title": "68664 NSA Oklahoma City OK", + "taskNumber": 42, + "status": "not_started", + "currentVersion": "R01", + "billedVersions": [ + "R00: INV-2026-006 (sent)" + ], + "completedUnbilledVersions": [], + "activeRevision": "R01", + "activeStatus": "not_started", + "r00Billed": true + }, + { + "site": "68666", + "checked": true, + "exists": true, + "title": "68666 NSA Oklahoma City, OK", + "taskNumber": 43, + "status": "not_started", + "currentVersion": "R01", + "billedVersions": [ + "R00: INV-2026-006 (sent)" + ], + "completedUnbilledVersions": [], + "activeRevision": "R01", + "activeStatus": "not_started", + "r00Billed": true + }, + { + "site": "68669", + "checked": true, + "exists": true, + "title": "68669 NSA Oklahoma City OK", + "taskNumber": 44, + "status": "not_started", + "currentVersion": "R01", + "billedVersions": [ + "R00: INV-2026-006 (sent)" + ], + "completedUnbilledVersions": [], + "activeRevision": "R01", + "activeStatus": "not_started", + "r00Billed": true + }, + { + "site": "68670", + "checked": true, + "exists": true, + "title": "68670 NSA Oklahoma City OK", + "taskNumber": 45, + "status": "client_review", + "currentVersion": "R02", + "billedVersions": [ + "R00: INV-2026-006 (sent)", + "R01: INV-2026-006 (sent)" + ], + "completedUnbilledVersions": [], + "activeRevision": "R02", + "activeStatus": "client_review", + "r00Billed": true + }, + { + "site": "68671", + "checked": true, + "exists": true, + "title": "68671 NSA Oklahoma City OK", + "taskNumber": 46, + "status": "client_review", + "currentVersion": "R01", + "billedVersions": [ + "R00: INV-2026-006 (sent)" + ], + "completedUnbilledVersions": [], + "activeRevision": "R01", + "activeStatus": "client_review", + "r00Billed": true + }, + { + "site": "68673", + "checked": true, + "exists": true, + "title": "68673 NSA Oklahoma City OK", + "taskNumber": 47, + "status": "client_review", + "currentVersion": "R01", + "billedVersions": [ + "R00: INV-2026-006 (sent)" + ], + "completedUnbilledVersions": [], + "activeRevision": "R01", + "activeStatus": "client_review", + "r00Billed": true + }, + { + "site": "68715", + "checked": false, + "exists": false + }, + { + "site": "68720", + "checked": true, + "exists": true, + "title": "68720 NSA Lancaster PA", + "taskNumber": 74, + "status": "client_approved", + "currentVersion": "R00", + "billedVersions": [], + "completedUnbilledVersions": [ + "R00" + ], + "activeRevision": "—", + "activeStatus": "—", + "r00Billed": false + }, + { + "site": "68721", + "checked": true, + "exists": true, + "title": "68721 NSA Mechanicsburg PA", + "taskNumber": 48, + "status": "client_approved", + "currentVersion": "R02", + "billedVersions": [ + "R00: INV-2026-006 (sent)", + "R01: INV-2026-006 (sent)" + ], + "completedUnbilledVersions": [ + "R02" + ], + "activeRevision": "R02", + "activeStatus": "client_approved", + "r00Billed": true + }, + { + "site": "68723", + "checked": false, + "exists": false + }, + { + "site": "68724", + "checked": true, + "exists": true, + "title": "68724 NSA York PA", + "taskNumber": 75, + "status": "invoiced", + "currentVersion": "R00", + "billedVersions": [ + "R00: INV-2026-006 (sent)" + ], + "completedUnbilledVersions": [], + "activeRevision": "—", + "activeStatus": "—", + "r00Billed": true + }, + { + "site": "68751", + "checked": false, + "exists": false + }, + { + "site": "68752", + "checked": true, + "exists": true, + "title": "68752 NSA Brownsville TX", + "taskNumber": 76, + "status": "invoiced", + "currentVersion": "R00", + "billedVersions": [ + "R00: INV-2026-006 (sent)" + ], + "completedUnbilledVersions": [], + "activeRevision": "—", + "activeStatus": "—", + "r00Billed": true + }, + { + "site": "68753", + "checked": true, + "exists": true, + "title": "68753 NSA Brownsville TX", + "taskNumber": 77, + "status": "invoiced", + "currentVersion": "R00", + "billedVersions": [ + "R00: INV-2026-006 (sent)" + ], + "completedUnbilledVersions": [], + "activeRevision": "—", + "activeStatus": "—", + "r00Billed": true + }, + { + "site": "68754", + "checked": false, + "exists": true, + "title": "68754 NSA Brownsville TX", + "taskNumber": 78, + "status": "in_progress", + "currentVersion": "R00", + "billedVersions": [], + "completedUnbilledVersions": [], + "activeRevision": "—", + "activeStatus": "—", + "r00Billed": false + }, + { + "site": "68755", + "checked": false, + "exists": true, + "title": "68755 NSA Brownsville TX", + "taskNumber": 79, + "status": "not_started", + "currentVersion": "R00", + "billedVersions": [], + "completedUnbilledVersions": [], + "activeRevision": "—", + "activeStatus": "—", + "r00Billed": false + }, + { + "site": "68756", + "checked": false, + "exists": true, + "title": "68756 NSA Brownsville TX", + "taskNumber": 80, + "status": "in_progress", + "currentVersion": "R00", + "billedVersions": [], + "completedUnbilledVersions": [], + "activeRevision": "—", + "activeStatus": "—", + "r00Billed": false + }, + { + "site": "68758", + "checked": true, + "exists": true, + "title": "68758 NSA Brownsville TX", + "taskNumber": 81, + "status": "invoiced", + "currentVersion": "R00", + "billedVersions": [ + "R00: INV-2026-006 (sent)" + ], + "completedUnbilledVersions": [], + "activeRevision": "—", + "activeStatus": "—", + "r00Billed": true + }, + { + "site": "68776", + "checked": false, + "exists": false + }, + { + "site": "68777", + "checked": false, + "exists": false + }, + { + "site": "68778", + "checked": false, + "exists": false + }, + { + "site": "68779", + "checked": false, + "exists": false + }, + { + "site": "68780", + "checked": false, + "exists": false + }, + { + "site": "68781", + "checked": false, + "exists": false + }, + { + "site": "68782", + "checked": false, + "exists": false + }, + { + "site": "68784", + "checked": false, + "exists": false + }, + { + "site": "68785", + "checked": false, + "exists": false + }, + { + "site": "68808", + "checked": true, + "exists": true, + "title": "68808 Camas, WA", + "taskNumber": 49, + "status": "not_started", + "currentVersion": "R01", + "billedVersions": [ + "R00: INV-2026-006 (sent)" + ], + "completedUnbilledVersions": [], + "activeRevision": "R01", + "activeStatus": "not_started", + "r00Billed": true + }, + { + "site": "68809", + "checked": true, + "exists": true, + "title": "68809 Centralia, WA", + "taskNumber": 50, + "status": "not_started", + "currentVersion": "R02", + "billedVersions": [ + "R00: INV-2026-006 (sent)", + "R01: INV-2026-006 (sent)" + ], + "completedUnbilledVersions": [], + "activeRevision": "R02", + "activeStatus": "not_started", + "r00Billed": true + }, + { + "site": "68810", + "checked": true, + "exists": true, + "title": "68810 Chehalis, WA", + "taskNumber": 51, + "status": "not_started", + "currentVersion": "R02", + "billedVersions": [ + "R00: INV-2026-006 (sent)", + "R01: INV-2026-006 (sent)" + ], + "completedUnbilledVersions": [], + "activeRevision": "R02", + "activeStatus": "not_started", + "r00Billed": true + }, + { + "site": "68811", + "checked": true, + "exists": true, + "title": "68811 Kelso, WA", + "taskNumber": 52, + "status": "not_started", + "currentVersion": "R02", + "billedVersions": [ + "R00: INV-2026-006 (sent)" + ], + "completedUnbilledVersions": [ + "R01" + ], + "activeRevision": "R02", + "activeStatus": "not_started", + "r00Billed": true + } + ] +} \ No newline at end of file diff --git a/docs/invoice-match-audit-2026-06-05.md b/docs/invoice-match-audit-2026-06-05.md new file mode 100644 index 0000000..6b4a749 --- /dev/null +++ b/docs/invoice-match-audit-2026-06-05.md @@ -0,0 +1,55 @@ +# Invoice Match Audit + +Date: `2026-06-05` + +Definition used: your checked sites mean `R00` was completed. This audit compares that list against live client invoice data. + +- Checked sites: 26 +- Sites found in DB: 39 +- Checked sites with R00 complete but not billed: 3 +- Checked sites with completed unbilled versions: 6 +- Unchecked sites that are already billed: 0 + +## Main Finding + +Client invoicing appears aligned with your checked list overall: there are **no unchecked sites currently billed** from this list. + +## Checked But R00 Not Billed Yet + +- `30094` 30094 Colorado Springs, CO -> R00, status `client_approved` +- `30261` 30261 NSA Clovis NM -> R00, status `client_approved` +- `68720` 68720 NSA Lancaster PA -> R00, status `client_approved` + +## Completed Versions Still Unbilled + +- `30094` 30094 Colorado Springs, CO -> unbilled: R00; billed: —; current R00 (client_approved) +- `30096` 30096 Eerie, CO -> unbilled: R01; billed: R00: INV-2026-006 (sent); current R02 (not_started) +- `30261` 30261 NSA Clovis NM -> unbilled: R00; billed: —; current R00 (client_approved) +- `68720` 68720 NSA Lancaster PA -> unbilled: R00; billed: —; current R00 (client_approved) +- `68721` 68721 NSA Mechanicsburg PA -> unbilled: R02; billed: R00: INV-2026-006 (sent) | R01: INV-2026-006 (sent); current R02 (client_approved) +- `68811` 68811 Kelso, WA -> unbilled: R01; billed: R00: INV-2026-006 (sent); current R02 (not_started) + +## Checked Sites With Active Revisions + +| Site | Title | Current | Status | Billed Versions | Completed Unbilled | +|---|---|---|---|---|---| +| 00286 | 00286 Riverside, CA | R01 | not_started | R00: INV-2026-006 (sent) | — | +| 30055 | 30055 Moreno Valley, CA | R01 | client_review | R00: INV-2026-006 (sent) | — | +| 30096 | 30096 Eerie, CO | R02 | not_started | R00: INV-2026-006 (sent) | R01 | +| 68639 | 68639 NSA Los Lunas NM | R01 | client_review | R00: INV-2026-006 (sent) | — | +| 68663 | 68663 NSA Oklahoma City OK | R01 | not_started | R00: INV-2026-006 (sent) | — | +| 68664 | 68664 NSA Oklahoma City OK | R01 | not_started | R00: INV-2026-006 (sent) | — | +| 68666 | 68666 NSA Oklahoma City, OK | R01 | not_started | R00: INV-2026-006 (sent) | — | +| 68669 | 68669 NSA Oklahoma City OK | R01 | not_started | R00: INV-2026-006 (sent) | — | +| 68670 | 68670 NSA Oklahoma City OK | R02 | client_review | R00: INV-2026-006 (sent)
R01: INV-2026-006 (sent) | — | +| 68671 | 68671 NSA Oklahoma City OK | R01 | client_review | R00: INV-2026-006 (sent) | — | +| 68673 | 68673 NSA Oklahoma City OK | R01 | client_review | R00: INV-2026-006 (sent) | — | +| 68721 | 68721 NSA Mechanicsburg PA | R02 | client_approved | R00: INV-2026-006 (sent)
R01: INV-2026-006 (sent) | R02 | +| 68808 | 68808 Camas, WA | R01 | not_started | R00: INV-2026-006 (sent) | — | +| 68809 | 68809 Centralia, WA | R02 | not_started | R00: INV-2026-006 (sent)
R01: INV-2026-006 (sent) | — | +| 68810 | 68810 Chehalis, WA | R02 | not_started | R00: INV-2026-006 (sent)
R01: INV-2026-006 (sent) | — | +| 68811 | 68811 Kelso, WA | R02 | not_started | R00: INV-2026-006 (sent) | R01 | + +## Unchecked But Already Billed + +- None \ No newline at end of file diff --git a/docs/site-audit-2026-06-04.json b/docs/site-audit-2026-06-04.json new file mode 100644 index 0000000..abf5040 --- /dev/null +++ b/docs/site-audit-2026-06-04.json @@ -0,0 +1,1340 @@ +{ + "rows": [ + { + "number": "00278", + "exists": false, + "markedComplete": false + }, + { + "number": "00279", + "exists": false, + "markedComplete": false + }, + { + "number": "00281", + "exists": false, + "markedComplete": false + }, + { + "number": "00282", + "exists": false, + "markedComplete": false + }, + { + "number": "00284", + "exists": false, + "markedComplete": false + }, + { + "number": "00285", + "exists": true, + "title": "00285 NSA Moreno Valley CA", + "internalTaskNumber": 65, + "status": "not_started", + "currentVersion": "R00", + "markedComplete": false, + "dbCompleted": false, + "match": true, + "r00Billed": false, + "r00BillDetails": "", + "billedRevisions": [], + "completedUnbilledRevisions": [], + "activeVersion": "R00", + "activeVersionStatus": "not_started", + "versionSummary": "R00: not_started" + }, + { + "number": "00286", + "exists": true, + "title": "00286 Riverside, CA", + "internalTaskNumber": 31, + "status": "not_started", + "currentVersion": "R01", + "markedComplete": true, + "dbCompleted": false, + "match": false, + "r00Billed": true, + "r00BillDetails": "INV-2026-006 (sent)", + "billedRevisions": [], + "completedUnbilledRevisions": [], + "activeVersion": "R01", + "activeVersionStatus": "not_started", + "versionSummary": "R00: completed / billed; R01: not_started" + }, + { + "number": "00290", + "exists": false, + "markedComplete": false + }, + { + "number": "00291", + "exists": false, + "markedComplete": false + }, + { + "number": "30049", + "exists": true, + "title": "30049 Elk Grove, CA", + "internalTaskNumber": 32, + "status": "not_started", + "currentVersion": "R00", + "markedComplete": false, + "dbCompleted": false, + "match": true, + "r00Billed": false, + "r00BillDetails": "", + "billedRevisions": [], + "completedUnbilledRevisions": [], + "activeVersion": "R00", + "activeVersionStatus": "not_started", + "versionSummary": "R00: not_started" + }, + { + "number": "30051", + "exists": false, + "markedComplete": false + }, + { + "number": "30054", + "exists": false, + "markedComplete": false + }, + { + "number": "30055", + "exists": true, + "title": "30055 Moreno Valley, CA", + "internalTaskNumber": 33, + "status": "client_review", + "currentVersion": "R01", + "markedComplete": true, + "dbCompleted": false, + "match": false, + "r00Billed": true, + "r00BillDetails": "INV-2026-006 (sent)", + "billedRevisions": [], + "completedUnbilledRevisions": [], + "activeVersion": "R01", + "activeVersionStatus": "client_review", + "versionSummary": "R00: completed / billed; R01: client_review" + }, + { + "number": "30056", + "exists": false, + "markedComplete": false + }, + { + "number": "30058", + "exists": false, + "markedComplete": false + }, + { + "number": "30060", + "exists": true, + "title": "30060 NSA Palmdale CA", + "internalTaskNumber": 66, + "status": "not_started", + "currentVersion": "R00", + "markedComplete": false, + "dbCompleted": false, + "match": true, + "r00Billed": false, + "r00BillDetails": "", + "billedRevisions": [], + "completedUnbilledRevisions": [], + "activeVersion": "R00", + "activeVersionStatus": "not_started", + "versionSummary": "R00: not_started" + }, + { + "number": "30061", + "exists": false, + "markedComplete": false + }, + { + "number": "30062", + "exists": true, + "title": "30062 Riverside, CA", + "internalTaskNumber": 34, + "status": "invoiced", + "currentVersion": "R00", + "markedComplete": true, + "dbCompleted": true, + "match": true, + "r00Billed": true, + "r00BillDetails": "INV-2026-006 (sent)", + "billedRevisions": [], + "completedUnbilledRevisions": [], + "activeVersion": "R00", + "activeVersionStatus": "invoiced", + "versionSummary": "R00: invoiced / billed" + }, + { + "number": "30063", + "exists": false, + "markedComplete": false + }, + { + "number": "30068", + "exists": false, + "markedComplete": false + }, + { + "number": "30091", + "exists": true, + "title": "30091 NSA Colorado Springs CO", + "internalTaskNumber": 67, + "status": "not_started", + "currentVersion": "R00", + "markedComplete": false, + "dbCompleted": false, + "match": true, + "r00Billed": false, + "r00BillDetails": "", + "billedRevisions": [], + "completedUnbilledRevisions": [], + "activeVersion": "R00", + "activeVersionStatus": "not_started", + "versionSummary": "R00: not_started" + }, + { + "number": "30094", + "exists": true, + "title": "30094 Colorado Springs, CO", + "internalTaskNumber": 35, + "status": "client_approved", + "currentVersion": "R00", + "markedComplete": true, + "dbCompleted": true, + "match": true, + "r00Billed": false, + "r00BillDetails": "", + "billedRevisions": [], + "completedUnbilledRevisions": [], + "activeVersion": "R00", + "activeVersionStatus": "client_approved", + "versionSummary": "R00: client_approved" + }, + { + "number": "30095", + "exists": false, + "markedComplete": false + }, + { + "number": "30096", + "exists": true, + "title": "30096 Eerie, CO", + "internalTaskNumber": 36, + "status": "not_started", + "currentVersion": "R02", + "markedComplete": true, + "dbCompleted": false, + "match": false, + "r00Billed": true, + "r00BillDetails": "INV-2026-006 (sent)", + "billedRevisions": [], + "completedUnbilledRevisions": [ + "R01" + ], + "activeVersion": "R02", + "activeVersionStatus": "not_started", + "versionSummary": "R00: completed / billed; R01: completed; R02: not_started" + }, + { + "number": "30098", + "exists": false, + "markedComplete": false + }, + { + "number": "30101", + "exists": true, + "title": "30101 Monument, CO", + "internalTaskNumber": 37, + "status": "on_hold", + "currentVersion": "R00", + "markedComplete": false, + "dbCompleted": false, + "match": true, + "r00Billed": false, + "r00BillDetails": "", + "billedRevisions": [], + "completedUnbilledRevisions": [], + "activeVersion": "R00", + "activeVersionStatus": "on_hold", + "versionSummary": "R00: on_hold" + }, + { + "number": "30210", + "exists": false, + "markedComplete": false + }, + { + "number": "30211", + "exists": true, + "title": "30211 NSA Post Falls ID", + "internalTaskNumber": 68, + "status": "not_started", + "currentVersion": "R00", + "markedComplete": false, + "dbCompleted": false, + "match": true, + "r00Billed": false, + "r00BillDetails": "", + "billedRevisions": [], + "completedUnbilledRevisions": [], + "activeVersion": "R00", + "activeVersionStatus": "not_started", + "versionSummary": "R00: not_started" + }, + { + "number": "30212", + "exists": true, + "title": "30212 NSA Sandpoint ID", + "internalTaskNumber": 69, + "status": "not_started", + "currentVersion": "R00", + "markedComplete": false, + "dbCompleted": false, + "match": true, + "r00Billed": false, + "r00BillDetails": "", + "billedRevisions": [], + "completedUnbilledRevisions": [], + "activeVersion": "R00", + "activeVersionStatus": "not_started", + "versionSummary": "R00: not_started" + }, + { + "number": "30258", + "exists": true, + "title": "30258 Lebanon, NH", + "internalTaskNumber": 38, + "status": "invoiced", + "currentVersion": "R00", + "markedComplete": true, + "dbCompleted": true, + "match": true, + "r00Billed": true, + "r00BillDetails": "INV-2026-006 (sent)", + "billedRevisions": [], + "completedUnbilledRevisions": [], + "activeVersion": "R00", + "activeVersionStatus": "invoiced", + "versionSummary": "R00: invoiced / billed" + }, + { + "number": "30259", + "exists": true, + "title": "30259 Hamburg, NJ", + "internalTaskNumber": 39, + "status": "invoiced", + "currentVersion": "R00", + "markedComplete": true, + "dbCompleted": true, + "match": true, + "r00Billed": true, + "r00BillDetails": "INV-2026-006 (sent)", + "billedRevisions": [], + "completedUnbilledRevisions": [], + "activeVersion": "R00", + "activeVersionStatus": "invoiced", + "versionSummary": "R00: invoiced / billed" + }, + { + "number": "30261", + "exists": true, + "title": "30261 NSA Clovis NM", + "internalTaskNumber": 70, + "status": "client_approved", + "currentVersion": "R00", + "markedComplete": true, + "dbCompleted": true, + "match": true, + "r00Billed": false, + "r00BillDetails": "", + "billedRevisions": [], + "completedUnbilledRevisions": [], + "activeVersion": "R00", + "activeVersionStatus": "client_approved", + "versionSummary": "R00: client_approved" + }, + { + "number": "30262", + "exists": false, + "markedComplete": false + }, + { + "number": "30319", + "exists": false, + "markedComplete": false + }, + { + "number": "30342", + "exists": false, + "markedComplete": false + }, + { + "number": "00288", + "exists": false, + "markedComplete": false + }, + { + "number": "30344", + "exists": false, + "markedComplete": false + }, + { + "number": "30345", + "exists": false, + "markedComplete": false + }, + { + "number": "30348", + "exists": false, + "markedComplete": false + }, + { + "number": "30349", + "exists": false, + "markedComplete": false + }, + { + "number": "30350", + "exists": false, + "markedComplete": false + }, + { + "number": "30355", + "exists": false, + "markedComplete": false + }, + { + "number": "30357", + "exists": false, + "markedComplete": false + }, + { + "number": "30407", + "exists": false, + "markedComplete": false + }, + { + "number": "68175", + "exists": false, + "markedComplete": false + }, + { + "number": "30412", + "exists": false, + "markedComplete": false + }, + { + "number": "30413", + "exists": false, + "markedComplete": false + }, + { + "number": "68215", + "exists": false, + "markedComplete": false + }, + { + "number": "68216", + "exists": false, + "markedComplete": false + }, + { + "number": "30437", + "exists": false, + "markedComplete": false + }, + { + "number": "68220", + "exists": false, + "markedComplete": false + }, + { + "number": "68432", + "exists": false, + "markedComplete": false + }, + { + "number": "68452", + "exists": true, + "title": "68452 NSA Houston TX", + "internalTaskNumber": 71, + "status": "not_started", + "currentVersion": "R00", + "markedComplete": false, + "dbCompleted": false, + "match": true, + "r00Billed": false, + "r00BillDetails": "", + "billedRevisions": [], + "completedUnbilledRevisions": [], + "activeVersion": "R00", + "activeVersionStatus": "not_started", + "versionSummary": "R00: not_started" + }, + { + "number": "68453", + "exists": true, + "title": "68453 NSA Katy TX", + "internalTaskNumber": 72, + "status": "not_started", + "currentVersion": "R00", + "markedComplete": false, + "dbCompleted": false, + "match": true, + "r00Billed": false, + "r00BillDetails": "", + "billedRevisions": [], + "completedUnbilledRevisions": [], + "activeVersion": "R00", + "activeVersionStatus": "not_started", + "versionSummary": "R00: not_started" + }, + { + "number": "68454", + "exists": true, + "title": "68454 NSA Katy TX", + "internalTaskNumber": 73, + "status": "not_started", + "currentVersion": "R00", + "markedComplete": false, + "dbCompleted": false, + "match": true, + "r00Billed": false, + "r00BillDetails": "", + "billedRevisions": [], + "completedUnbilledRevisions": [], + "activeVersion": "R00", + "activeVersionStatus": "not_started", + "versionSummary": "R00: not_started" + }, + { + "number": "68516", + "exists": false, + "markedComplete": false + }, + { + "number": "68617", + "exists": false, + "markedComplete": false + }, + { + "number": "68620", + "exists": false, + "markedComplete": false + }, + { + "number": "68631", + "exists": false, + "markedComplete": false + }, + { + "number": "68636", + "exists": false, + "markedComplete": false + }, + { + "number": "68637", + "exists": false, + "markedComplete": false + }, + { + "number": "68638", + "exists": false, + "markedComplete": false + }, + { + "number": "68639", + "exists": true, + "title": "68639 NSA Los Lunas NM", + "internalTaskNumber": 40, + "status": "client_review", + "currentVersion": "R01", + "markedComplete": true, + "dbCompleted": false, + "match": false, + "r00Billed": true, + "r00BillDetails": "INV-2026-006 (sent)", + "billedRevisions": [], + "completedUnbilledRevisions": [], + "activeVersion": "R01", + "activeVersionStatus": "client_review", + "versionSummary": "R00: completed / billed; R01: client_review" + }, + { + "number": "68663", + "exists": true, + "title": "68663 NSA Oklahoma City OK", + "internalTaskNumber": 41, + "status": "not_started", + "currentVersion": "R01", + "markedComplete": true, + "dbCompleted": false, + "match": false, + "r00Billed": true, + "r00BillDetails": "INV-2026-006 (sent)", + "billedRevisions": [], + "completedUnbilledRevisions": [], + "activeVersion": "R01", + "activeVersionStatus": "not_started", + "versionSummary": "R00: completed / billed; R01: not_started" + }, + { + "number": "68664", + "exists": true, + "title": "68664 NSA Oklahoma City OK", + "internalTaskNumber": 42, + "status": "not_started", + "currentVersion": "R01", + "markedComplete": true, + "dbCompleted": false, + "match": false, + "r00Billed": true, + "r00BillDetails": "INV-2026-006 (sent)", + "billedRevisions": [], + "completedUnbilledRevisions": [], + "activeVersion": "R01", + "activeVersionStatus": "not_started", + "versionSummary": "R00: completed / billed; R01: not_started" + }, + { + "number": "68666", + "exists": true, + "title": "68666 NSA Oklahoma City, OK", + "internalTaskNumber": 43, + "status": "not_started", + "currentVersion": "R01", + "markedComplete": true, + "dbCompleted": false, + "match": false, + "r00Billed": true, + "r00BillDetails": "INV-2026-006 (sent)", + "billedRevisions": [], + "completedUnbilledRevisions": [], + "activeVersion": "R01", + "activeVersionStatus": "not_started", + "versionSummary": "R00: completed / billed; R01: not_started" + }, + { + "number": "68669", + "exists": true, + "title": "68669 NSA Oklahoma City OK", + "internalTaskNumber": 44, + "status": "not_started", + "currentVersion": "R01", + "markedComplete": true, + "dbCompleted": false, + "match": false, + "r00Billed": true, + "r00BillDetails": "INV-2026-006 (sent)", + "billedRevisions": [], + "completedUnbilledRevisions": [], + "activeVersion": "R01", + "activeVersionStatus": "not_started", + "versionSummary": "R00: completed / billed; R01: not_started" + }, + { + "number": "68670", + "exists": true, + "title": "68670 NSA Oklahoma City OK", + "internalTaskNumber": 45, + "status": "not_started", + "currentVersion": "R02", + "markedComplete": true, + "dbCompleted": false, + "match": false, + "r00Billed": true, + "r00BillDetails": "INV-2026-006 (sent)", + "billedRevisions": [ + "R01 [INV-2026-006 (sent)]" + ], + "completedUnbilledRevisions": [], + "activeVersion": "R02", + "activeVersionStatus": "not_started", + "versionSummary": "R00: completed / billed; R01: completed / billed; R02: not_started" + }, + { + "number": "68671", + "exists": true, + "title": "68671 NSA Oklahoma City OK", + "internalTaskNumber": 46, + "status": "not_started", + "currentVersion": "R01", + "markedComplete": true, + "dbCompleted": false, + "match": false, + "r00Billed": true, + "r00BillDetails": "INV-2026-006 (sent)", + "billedRevisions": [], + "completedUnbilledRevisions": [], + "activeVersion": "R01", + "activeVersionStatus": "not_started", + "versionSummary": "R00: completed / billed; R01: not_started" + }, + { + "number": "68673", + "exists": true, + "title": "68673 NSA Oklahoma City OK", + "internalTaskNumber": 47, + "status": "client_review", + "currentVersion": "R01", + "markedComplete": true, + "dbCompleted": false, + "match": false, + "r00Billed": true, + "r00BillDetails": "INV-2026-006 (sent)", + "billedRevisions": [], + "completedUnbilledRevisions": [], + "activeVersion": "R01", + "activeVersionStatus": "client_review", + "versionSummary": "R00: completed / billed; R01: client_review" + }, + { + "number": "68715", + "exists": false, + "markedComplete": false + }, + { + "number": "68720", + "exists": true, + "title": "68720 NSA Lancaster PA", + "internalTaskNumber": 74, + "status": "client_approved", + "currentVersion": "R00", + "markedComplete": true, + "dbCompleted": true, + "match": true, + "r00Billed": false, + "r00BillDetails": "", + "billedRevisions": [], + "completedUnbilledRevisions": [], + "activeVersion": "R00", + "activeVersionStatus": "client_approved", + "versionSummary": "R00: client_approved" + }, + { + "number": "68721", + "exists": true, + "title": "68721 NSA Mechanicsburg PA", + "internalTaskNumber": 48, + "status": "client_approved", + "currentVersion": "R02", + "markedComplete": true, + "dbCompleted": true, + "match": true, + "r00Billed": true, + "r00BillDetails": "INV-2026-006 (sent)", + "billedRevisions": [ + "R01 [INV-2026-006 (sent)]" + ], + "completedUnbilledRevisions": [ + "R02" + ], + "activeVersion": "R02", + "activeVersionStatus": "client_approved", + "versionSummary": "R00: completed / billed; R01: completed / billed; R02: client_approved" + }, + { + "number": "68723", + "exists": false, + "markedComplete": false + }, + { + "number": "68724", + "exists": true, + "title": "68724 NSA York PA", + "internalTaskNumber": 75, + "status": "invoiced", + "currentVersion": "R00", + "markedComplete": true, + "dbCompleted": true, + "match": true, + "r00Billed": true, + "r00BillDetails": "INV-2026-006 (sent)", + "billedRevisions": [], + "completedUnbilledRevisions": [], + "activeVersion": "R00", + "activeVersionStatus": "invoiced", + "versionSummary": "R00: invoiced / billed" + }, + { + "number": "68751", + "exists": false, + "markedComplete": false + }, + { + "number": "68752", + "exists": true, + "title": "68752 NSA Brownsville TX", + "internalTaskNumber": 76, + "status": "invoiced", + "currentVersion": "R00", + "markedComplete": true, + "dbCompleted": true, + "match": true, + "r00Billed": true, + "r00BillDetails": "INV-2026-006 (sent)", + "billedRevisions": [], + "completedUnbilledRevisions": [], + "activeVersion": "R00", + "activeVersionStatus": "invoiced", + "versionSummary": "R00: invoiced / billed" + }, + { + "number": "68753", + "exists": true, + "title": "68753 NSA Brownsville TX", + "internalTaskNumber": 77, + "status": "invoiced", + "currentVersion": "R00", + "markedComplete": true, + "dbCompleted": true, + "match": true, + "r00Billed": true, + "r00BillDetails": "INV-2026-006 (sent)", + "billedRevisions": [], + "completedUnbilledRevisions": [], + "activeVersion": "R00", + "activeVersionStatus": "invoiced", + "versionSummary": "R00: invoiced / billed" + }, + { + "number": "68754", + "exists": true, + "title": "68754 NSA Brownsville TX", + "internalTaskNumber": 78, + "status": "in_progress", + "currentVersion": "R00", + "markedComplete": false, + "dbCompleted": false, + "match": true, + "r00Billed": false, + "r00BillDetails": "", + "billedRevisions": [], + "completedUnbilledRevisions": [], + "activeVersion": "R00", + "activeVersionStatus": "in_progress", + "versionSummary": "R00: in_progress" + }, + { + "number": "68755", + "exists": true, + "title": "68755 NSA Brownsville TX", + "internalTaskNumber": 79, + "status": "not_started", + "currentVersion": "R00", + "markedComplete": false, + "dbCompleted": false, + "match": true, + "r00Billed": false, + "r00BillDetails": "", + "billedRevisions": [], + "completedUnbilledRevisions": [], + "activeVersion": "R00", + "activeVersionStatus": "not_started", + "versionSummary": "R00: not_started" + }, + { + "number": "68756", + "exists": true, + "title": "68756 NSA Brownsville TX", + "internalTaskNumber": 80, + "status": "in_progress", + "currentVersion": "R00", + "markedComplete": false, + "dbCompleted": false, + "match": true, + "r00Billed": false, + "r00BillDetails": "", + "billedRevisions": [], + "completedUnbilledRevisions": [], + "activeVersion": "R00", + "activeVersionStatus": "in_progress", + "versionSummary": "R00: in_progress" + }, + { + "number": "68758", + "exists": true, + "title": "68758 NSA Brownsville TX", + "internalTaskNumber": 81, + "status": "invoiced", + "currentVersion": "R00", + "markedComplete": true, + "dbCompleted": true, + "match": true, + "r00Billed": true, + "r00BillDetails": "INV-2026-006 (sent)", + "billedRevisions": [], + "completedUnbilledRevisions": [], + "activeVersion": "R00", + "activeVersionStatus": "invoiced", + "versionSummary": "R00: invoiced / billed" + }, + { + "number": "68776", + "exists": false, + "markedComplete": false + }, + { + "number": "68777", + "exists": false, + "markedComplete": false + }, + { + "number": "68778", + "exists": false, + "markedComplete": false + }, + { + "number": "68779", + "exists": false, + "markedComplete": false + }, + { + "number": "68780", + "exists": false, + "markedComplete": false + }, + { + "number": "68781", + "exists": false, + "markedComplete": false + }, + { + "number": "68782", + "exists": false, + "markedComplete": false + }, + { + "number": "68784", + "exists": false, + "markedComplete": false + }, + { + "number": "68785", + "exists": false, + "markedComplete": false + }, + { + "number": "68808", + "exists": true, + "title": "68808 Camas, WA", + "internalTaskNumber": 49, + "status": "not_started", + "currentVersion": "R01", + "markedComplete": true, + "dbCompleted": false, + "match": false, + "r00Billed": true, + "r00BillDetails": "INV-2026-006 (sent)", + "billedRevisions": [], + "completedUnbilledRevisions": [], + "activeVersion": "R01", + "activeVersionStatus": "not_started", + "versionSummary": "R00: completed / billed; R01: not_started" + }, + { + "number": "68809", + "exists": true, + "title": "68809 Centralia, WA", + "internalTaskNumber": 50, + "status": "not_started", + "currentVersion": "R02", + "markedComplete": true, + "dbCompleted": false, + "match": false, + "r00Billed": true, + "r00BillDetails": "INV-2026-006 (sent)", + "billedRevisions": [ + "R01 [INV-2026-006 (sent)]" + ], + "completedUnbilledRevisions": [], + "activeVersion": "R02", + "activeVersionStatus": "not_started", + "versionSummary": "R00: completed / billed; R01: completed / billed; R02: not_started" + }, + { + "number": "68810", + "exists": true, + "title": "68810 Chehalis, WA", + "internalTaskNumber": 51, + "status": "not_started", + "currentVersion": "R02", + "markedComplete": true, + "dbCompleted": false, + "match": false, + "r00Billed": true, + "r00BillDetails": "INV-2026-006 (sent)", + "billedRevisions": [ + "R01 [INV-2026-006 (sent)]" + ], + "completedUnbilledRevisions": [], + "activeVersion": "R02", + "activeVersionStatus": "not_started", + "versionSummary": "R00: completed / billed; R01: completed / billed; R02: not_started" + }, + { + "number": "68811", + "exists": true, + "title": "68811 Kelso, WA", + "internalTaskNumber": 52, + "status": "not_started", + "currentVersion": "R02", + "markedComplete": true, + "dbCompleted": false, + "match": false, + "r00Billed": true, + "r00BillDetails": "INV-2026-006 (sent)", + "billedRevisions": [], + "completedUnbilledRevisions": [ + "R01" + ], + "activeVersion": "R02", + "activeVersionStatus": "not_started", + "versionSummary": "R00: completed / billed; R01: completed; R02: not_started" + } + ], + "missing": [ + "00278", + "00279", + "00281", + "00282", + "00284", + "00290", + "00291", + "30051", + "30054", + "30056", + "30058", + "30061", + "30063", + "30068", + "30095", + "30098", + "30210", + "30262", + "30319", + "30342", + "00288", + "30344", + "30345", + "30348", + "30349", + "30350", + "30355", + "30357", + "30407", + "68175", + "30412", + "30413", + "68215", + "68216", + "30437", + "68220", + "68432", + "68516", + "68617", + "68620", + "68631", + "68636", + "68637", + "68638", + "68715", + "68723", + "68751", + "68776", + "68777", + "68778", + "68779", + "68780", + "68781", + "68782", + "68784", + "68785" + ], + "mismatches": [ + { + "number": "00286", + "exists": true, + "title": "00286 Riverside, CA", + "internalTaskNumber": 31, + "status": "not_started", + "currentVersion": "R01", + "markedComplete": true, + "dbCompleted": false, + "match": false, + "r00Billed": true, + "r00BillDetails": "INV-2026-006 (sent)", + "billedRevisions": [], + "completedUnbilledRevisions": [], + "activeVersion": "R01", + "activeVersionStatus": "not_started", + "versionSummary": "R00: completed / billed; R01: not_started" + }, + { + "number": "30055", + "exists": true, + "title": "30055 Moreno Valley, CA", + "internalTaskNumber": 33, + "status": "client_review", + "currentVersion": "R01", + "markedComplete": true, + "dbCompleted": false, + "match": false, + "r00Billed": true, + "r00BillDetails": "INV-2026-006 (sent)", + "billedRevisions": [], + "completedUnbilledRevisions": [], + "activeVersion": "R01", + "activeVersionStatus": "client_review", + "versionSummary": "R00: completed / billed; R01: client_review" + }, + { + "number": "30096", + "exists": true, + "title": "30096 Eerie, CO", + "internalTaskNumber": 36, + "status": "not_started", + "currentVersion": "R02", + "markedComplete": true, + "dbCompleted": false, + "match": false, + "r00Billed": true, + "r00BillDetails": "INV-2026-006 (sent)", + "billedRevisions": [], + "completedUnbilledRevisions": [ + "R01" + ], + "activeVersion": "R02", + "activeVersionStatus": "not_started", + "versionSummary": "R00: completed / billed; R01: completed; R02: not_started" + }, + { + "number": "68639", + "exists": true, + "title": "68639 NSA Los Lunas NM", + "internalTaskNumber": 40, + "status": "client_review", + "currentVersion": "R01", + "markedComplete": true, + "dbCompleted": false, + "match": false, + "r00Billed": true, + "r00BillDetails": "INV-2026-006 (sent)", + "billedRevisions": [], + "completedUnbilledRevisions": [], + "activeVersion": "R01", + "activeVersionStatus": "client_review", + "versionSummary": "R00: completed / billed; R01: client_review" + }, + { + "number": "68663", + "exists": true, + "title": "68663 NSA Oklahoma City OK", + "internalTaskNumber": 41, + "status": "not_started", + "currentVersion": "R01", + "markedComplete": true, + "dbCompleted": false, + "match": false, + "r00Billed": true, + "r00BillDetails": "INV-2026-006 (sent)", + "billedRevisions": [], + "completedUnbilledRevisions": [], + "activeVersion": "R01", + "activeVersionStatus": "not_started", + "versionSummary": "R00: completed / billed; R01: not_started" + }, + { + "number": "68664", + "exists": true, + "title": "68664 NSA Oklahoma City OK", + "internalTaskNumber": 42, + "status": "not_started", + "currentVersion": "R01", + "markedComplete": true, + "dbCompleted": false, + "match": false, + "r00Billed": true, + "r00BillDetails": "INV-2026-006 (sent)", + "billedRevisions": [], + "completedUnbilledRevisions": [], + "activeVersion": "R01", + "activeVersionStatus": "not_started", + "versionSummary": "R00: completed / billed; R01: not_started" + }, + { + "number": "68666", + "exists": true, + "title": "68666 NSA Oklahoma City, OK", + "internalTaskNumber": 43, + "status": "not_started", + "currentVersion": "R01", + "markedComplete": true, + "dbCompleted": false, + "match": false, + "r00Billed": true, + "r00BillDetails": "INV-2026-006 (sent)", + "billedRevisions": [], + "completedUnbilledRevisions": [], + "activeVersion": "R01", + "activeVersionStatus": "not_started", + "versionSummary": "R00: completed / billed; R01: not_started" + }, + { + "number": "68669", + "exists": true, + "title": "68669 NSA Oklahoma City OK", + "internalTaskNumber": 44, + "status": "not_started", + "currentVersion": "R01", + "markedComplete": true, + "dbCompleted": false, + "match": false, + "r00Billed": true, + "r00BillDetails": "INV-2026-006 (sent)", + "billedRevisions": [], + "completedUnbilledRevisions": [], + "activeVersion": "R01", + "activeVersionStatus": "not_started", + "versionSummary": "R00: completed / billed; R01: not_started" + }, + { + "number": "68670", + "exists": true, + "title": "68670 NSA Oklahoma City OK", + "internalTaskNumber": 45, + "status": "not_started", + "currentVersion": "R02", + "markedComplete": true, + "dbCompleted": false, + "match": false, + "r00Billed": true, + "r00BillDetails": "INV-2026-006 (sent)", + "billedRevisions": [ + "R01 [INV-2026-006 (sent)]" + ], + "completedUnbilledRevisions": [], + "activeVersion": "R02", + "activeVersionStatus": "not_started", + "versionSummary": "R00: completed / billed; R01: completed / billed; R02: not_started" + }, + { + "number": "68671", + "exists": true, + "title": "68671 NSA Oklahoma City OK", + "internalTaskNumber": 46, + "status": "not_started", + "currentVersion": "R01", + "markedComplete": true, + "dbCompleted": false, + "match": false, + "r00Billed": true, + "r00BillDetails": "INV-2026-006 (sent)", + "billedRevisions": [], + "completedUnbilledRevisions": [], + "activeVersion": "R01", + "activeVersionStatus": "not_started", + "versionSummary": "R00: completed / billed; R01: not_started" + }, + { + "number": "68673", + "exists": true, + "title": "68673 NSA Oklahoma City OK", + "internalTaskNumber": 47, + "status": "client_review", + "currentVersion": "R01", + "markedComplete": true, + "dbCompleted": false, + "match": false, + "r00Billed": true, + "r00BillDetails": "INV-2026-006 (sent)", + "billedRevisions": [], + "completedUnbilledRevisions": [], + "activeVersion": "R01", + "activeVersionStatus": "client_review", + "versionSummary": "R00: completed / billed; R01: client_review" + }, + { + "number": "68808", + "exists": true, + "title": "68808 Camas, WA", + "internalTaskNumber": 49, + "status": "not_started", + "currentVersion": "R01", + "markedComplete": true, + "dbCompleted": false, + "match": false, + "r00Billed": true, + "r00BillDetails": "INV-2026-006 (sent)", + "billedRevisions": [], + "completedUnbilledRevisions": [], + "activeVersion": "R01", + "activeVersionStatus": "not_started", + "versionSummary": "R00: completed / billed; R01: not_started" + }, + { + "number": "68809", + "exists": true, + "title": "68809 Centralia, WA", + "internalTaskNumber": 50, + "status": "not_started", + "currentVersion": "R02", + "markedComplete": true, + "dbCompleted": false, + "match": false, + "r00Billed": true, + "r00BillDetails": "INV-2026-006 (sent)", + "billedRevisions": [ + "R01 [INV-2026-006 (sent)]" + ], + "completedUnbilledRevisions": [], + "activeVersion": "R02", + "activeVersionStatus": "not_started", + "versionSummary": "R00: completed / billed; R01: completed / billed; R02: not_started" + }, + { + "number": "68810", + "exists": true, + "title": "68810 Chehalis, WA", + "internalTaskNumber": 51, + "status": "not_started", + "currentVersion": "R02", + "markedComplete": true, + "dbCompleted": false, + "match": false, + "r00Billed": true, + "r00BillDetails": "INV-2026-006 (sent)", + "billedRevisions": [ + "R01 [INV-2026-006 (sent)]" + ], + "completedUnbilledRevisions": [], + "activeVersion": "R02", + "activeVersionStatus": "not_started", + "versionSummary": "R00: completed / billed; R01: completed / billed; R02: not_started" + }, + { + "number": "68811", + "exists": true, + "title": "68811 Kelso, WA", + "internalTaskNumber": 52, + "status": "not_started", + "currentVersion": "R02", + "markedComplete": true, + "dbCompleted": false, + "match": false, + "r00Billed": true, + "r00BillDetails": "INV-2026-006 (sent)", + "billedRevisions": [], + "completedUnbilledRevisions": [ + "R01" + ], + "activeVersion": "R02", + "activeVersionStatus": "not_started", + "versionSummary": "R00: completed / billed; R01: completed; R02: not_started" + } + ] +} \ No newline at end of file diff --git a/docs/site-audit-2026-06-04.md b/docs/site-audit-2026-06-04.md new file mode 100644 index 0000000..67f69ea --- /dev/null +++ b/docs/site-audit-2026-06-04.md @@ -0,0 +1,185 @@ +# Site Completion and Billing Audit + +Generated: 2026-06-04T13:58:27.846Z + +- Listed sites: 95 +- Found in database: 39 +- Missing from database: 56 +- Checked vs DB mismatches: 15 + +## Missing From Database + +- 00278 +- 00279 +- 00281 +- 00282 +- 00284 +- 00290 +- 00291 +- 30051 +- 30054 +- 30056 +- 30058 +- 30061 +- 30063 +- 30068 +- 30095 +- 30098 +- 30210 +- 30262 +- 30319 +- 30342 +- 00288 +- 30344 +- 30345 +- 30348 +- 30349 +- 30350 +- 30355 +- 30357 +- 30407 +- 68175 +- 30412 +- 30413 +- 68215 +- 68216 +- 30437 +- 68220 +- 68432 +- 68516 +- 68617 +- 68620 +- 68631 +- 68636 +- 68637 +- 68638 +- 68715 +- 68723 +- 68751 +- 68776 +- 68777 +- 68778 +- 68779 +- 68780 +- 68781 +- 68782 +- 68784 +- 68785 + +## Checked vs Database Mismatches + +- 00286 00286 Riverside, CA: you marked completed, DB status is not_started +- 30055 30055 Moreno Valley, CA: you marked completed, DB status is client_review +- 30096 30096 Eerie, CO: you marked completed, DB status is not_started +- 68639 68639 NSA Los Lunas NM: you marked completed, DB status is client_review +- 68663 68663 NSA Oklahoma City OK: you marked completed, DB status is not_started +- 68664 68664 NSA Oklahoma City OK: you marked completed, DB status is not_started +- 68666 68666 NSA Oklahoma City, OK: you marked completed, DB status is not_started +- 68669 68669 NSA Oklahoma City OK: you marked completed, DB status is not_started +- 68670 68670 NSA Oklahoma City OK: you marked completed, DB status is not_started +- 68671 68671 NSA Oklahoma City OK: you marked completed, DB status is not_started +- 68673 68673 NSA Oklahoma City OK: you marked completed, DB status is client_review +- 68808 68808 Camas, WA: you marked completed, DB status is not_started +- 68809 68809 Centralia, WA: you marked completed, DB status is not_started +- 68810 68810 Chehalis, WA: you marked completed, DB status is not_started +- 68811 68811 Kelso, WA: you marked completed, DB status is not_started + +## Audit Table + +| Site # | DB Title | Internal # | In DB | Your Check | DB Completed | Match | Current R# | Active Status | R00 Billed | Billed Revisions | Completed Unbilled Revisions | Version Status Summary | +|---|---|---:|---:|---:|---:|---:|---|---|---|---|---|---| +| 00278 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 00279 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 00281 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 00282 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 00284 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 00285 | 00285 NSA Moreno Valley CA | 65 | Yes | No | No | Yes | R00 | not_started | No | — | — | R00: not_started | +| 00286 | 00286 Riverside, CA | 31 | Yes | Yes | No | No | R01 | not_started | Yes (INV-2026-006 (sent)) | — | — | R00: completed / billed; R01: not_started | +| 00290 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 00291 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 30049 | 30049 Elk Grove, CA | 32 | Yes | No | No | Yes | R00 | not_started | No | — | — | R00: not_started | +| 30051 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 30054 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 30055 | 30055 Moreno Valley, CA | 33 | Yes | Yes | No | No | R01 | client_review | Yes (INV-2026-006 (sent)) | — | — | R00: completed / billed; R01: client_review | +| 30056 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 30058 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 30060 | 30060 NSA Palmdale CA | 66 | Yes | No | No | Yes | R00 | not_started | No | — | — | R00: not_started | +| 30061 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 30062 | 30062 Riverside, CA | 34 | Yes | Yes | Yes | Yes | R00 | invoiced | Yes (INV-2026-006 (sent)) | — | — | R00: invoiced / billed | +| 30063 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 30068 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 30091 | 30091 NSA Colorado Springs CO | 67 | Yes | No | No | Yes | R00 | not_started | No | — | — | R00: not_started | +| 30094 | 30094 Colorado Springs, CO | 35 | Yes | Yes | Yes | Yes | R00 | client_approved | No | — | — | R00: client_approved | +| 30095 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 30096 | 30096 Eerie, CO | 36 | Yes | Yes | No | No | R02 | not_started | Yes (INV-2026-006 (sent)) | — | R01 | R00: completed / billed; R01: completed; R02: not_started | +| 30098 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 30101 | 30101 Monument, CO | 37 | Yes | No | No | Yes | R00 | on_hold | No | — | — | R00: on_hold | +| 30210 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 30211 | 30211 NSA Post Falls ID | 68 | Yes | No | No | Yes | R00 | not_started | No | — | — | R00: not_started | +| 30212 | 30212 NSA Sandpoint ID | 69 | Yes | No | No | Yes | R00 | not_started | No | — | — | R00: not_started | +| 30258 | 30258 Lebanon, NH | 38 | Yes | Yes | Yes | Yes | R00 | invoiced | Yes (INV-2026-006 (sent)) | — | — | R00: invoiced / billed | +| 30259 | 30259 Hamburg, NJ | 39 | Yes | Yes | Yes | Yes | R00 | invoiced | Yes (INV-2026-006 (sent)) | — | — | R00: invoiced / billed | +| 30261 | 30261 NSA Clovis NM | 70 | Yes | Yes | Yes | Yes | R00 | client_approved | No | — | — | R00: client_approved | +| 30262 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 30319 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 30342 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 00288 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 30344 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 30345 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 30348 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 30349 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 30350 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 30355 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 30357 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 30407 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 68175 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 30412 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 30413 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 68215 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 68216 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 30437 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 68220 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 68432 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 68452 | 68452 NSA Houston TX | 71 | Yes | No | No | Yes | R00 | not_started | No | — | — | R00: not_started | +| 68453 | 68453 NSA Katy TX | 72 | Yes | No | No | Yes | R00 | not_started | No | — | — | R00: not_started | +| 68454 | 68454 NSA Katy TX | 73 | Yes | No | No | Yes | R00 | not_started | No | — | — | R00: not_started | +| 68516 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 68617 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 68620 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 68631 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 68636 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 68637 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 68638 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 68639 | 68639 NSA Los Lunas NM | 40 | Yes | Yes | No | No | R01 | client_review | Yes (INV-2026-006 (sent)) | — | — | R00: completed / billed; R01: client_review | +| 68663 | 68663 NSA Oklahoma City OK | 41 | Yes | Yes | No | No | R01 | not_started | Yes (INV-2026-006 (sent)) | — | — | R00: completed / billed; R01: not_started | +| 68664 | 68664 NSA Oklahoma City OK | 42 | Yes | Yes | No | No | R01 | not_started | Yes (INV-2026-006 (sent)) | — | — | R00: completed / billed; R01: not_started | +| 68666 | 68666 NSA Oklahoma City, OK | 43 | Yes | Yes | No | No | R01 | not_started | Yes (INV-2026-006 (sent)) | — | — | R00: completed / billed; R01: not_started | +| 68669 | 68669 NSA Oklahoma City OK | 44 | Yes | Yes | No | No | R01 | not_started | Yes (INV-2026-006 (sent)) | — | — | R00: completed / billed; R01: not_started | +| 68670 | 68670 NSA Oklahoma City OK | 45 | Yes | Yes | No | No | R02 | not_started | Yes (INV-2026-006 (sent)) | R01 [INV-2026-006 (sent)] | — | R00: completed / billed; R01: completed / billed; R02: not_started | +| 68671 | 68671 NSA Oklahoma City OK | 46 | Yes | Yes | No | No | R01 | not_started | Yes (INV-2026-006 (sent)) | — | — | R00: completed / billed; R01: not_started | +| 68673 | 68673 NSA Oklahoma City OK | 47 | Yes | Yes | No | No | R01 | client_review | Yes (INV-2026-006 (sent)) | — | — | R00: completed / billed; R01: client_review | +| 68715 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 68720 | 68720 NSA Lancaster PA | 74 | Yes | Yes | Yes | Yes | R00 | client_approved | No | — | — | R00: client_approved | +| 68721 | 68721 NSA Mechanicsburg PA | 48 | Yes | Yes | Yes | Yes | R02 | client_approved | Yes (INV-2026-006 (sent)) | R01 [INV-2026-006 (sent)] | R02 | R00: completed / billed; R01: completed / billed; R02: client_approved | +| 68723 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 68724 | 68724 NSA York PA | 75 | Yes | Yes | Yes | Yes | R00 | invoiced | Yes (INV-2026-006 (sent)) | — | — | R00: invoiced / billed | +| 68751 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 68752 | 68752 NSA Brownsville TX | 76 | Yes | Yes | Yes | Yes | R00 | invoiced | Yes (INV-2026-006 (sent)) | — | — | R00: invoiced / billed | +| 68753 | 68753 NSA Brownsville TX | 77 | Yes | Yes | Yes | Yes | R00 | invoiced | Yes (INV-2026-006 (sent)) | — | — | R00: invoiced / billed | +| 68754 | 68754 NSA Brownsville TX | 78 | Yes | No | No | Yes | R00 | in_progress | No | — | — | R00: in_progress | +| 68755 | 68755 NSA Brownsville TX | 79 | Yes | No | No | Yes | R00 | not_started | No | — | — | R00: not_started | +| 68756 | 68756 NSA Brownsville TX | 80 | Yes | No | No | Yes | R00 | in_progress | No | — | — | R00: in_progress | +| 68758 | 68758 NSA Brownsville TX | 81 | Yes | Yes | Yes | Yes | R00 | invoiced | Yes (INV-2026-006 (sent)) | — | — | R00: invoiced / billed | +| 68776 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 68777 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 68778 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 68779 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 68780 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 68781 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 68782 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 68784 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 68785 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB | +| 68808 | 68808 Camas, WA | 49 | Yes | Yes | No | No | R01 | not_started | Yes (INV-2026-006 (sent)) | — | — | R00: completed / billed; R01: not_started | +| 68809 | 68809 Centralia, WA | 50 | Yes | Yes | No | No | R02 | not_started | Yes (INV-2026-006 (sent)) | R01 [INV-2026-006 (sent)] | — | R00: completed / billed; R01: completed / billed; R02: not_started | +| 68810 | 68810 Chehalis, WA | 51 | Yes | Yes | No | No | R02 | not_started | Yes (INV-2026-006 (sent)) | R01 [INV-2026-006 (sent)] | — | R00: completed / billed; R01: completed / billed; R02: not_started | +| 68811 | 68811 Kelso, WA | 52 | Yes | Yes | No | No | R02 | not_started | Yes (INV-2026-006 (sent)) | — | R01 | R00: completed / billed; R01: completed; R02: not_started | \ No newline at end of file diff --git a/index.html b/index.html index c066899..be471f2 100644 --- a/index.html +++ b/index.html @@ -9,6 +9,8 @@ document.documentElement.style.colorScheme = theme === 'light' ? 'light' : 'dark'; })(); + + diff --git a/layout.md b/layout.md index e381e07..2fd64e6 100644 --- a/layout.md +++ b/layout.md @@ -15,6 +15,15 @@ This is the single source of truth for dashboard/profile visual structure and UI - radial glow + vertical dark gradient. - Light base token `--bg` is full gradient: - gray radial glow + white vertical gradient. +- A faint ambient grid may sit above the base gradient but behind all app content: + - use a fixed `body::before` layer with very low-contrast line color + - keep the static grid a touch more visible than before, but still quiet + - any animation should be tiny spark nodes that travel on the grid lines, not wide screen sweeps + - use several small sparks across the page with varied directions so the motion feels distributed rather than like one falling line + - avoid additional decorative shell/menu entrance animations so the sparks are the only ambient page motion + - keep animation slow and drifting, not pulsing or flashy + - keep the grid masked/faded so it is strongest near the top and softer lower in the page +- Auth/login screens use the same shared `body` background and spark effect as the app shell; auth surfaces should stay transparent around the card so the global background remains visible. - Do not use `html` theme-gradient scripting for Safari chrome behavior. ## 3) Tokens @@ -66,12 +75,28 @@ This is the single source of truth for dashboard/profile visual structure and UI - **`margin-bottom: 10` is the site-wide standard gap between any tab/control bar and the card(s) below — use this everywhere, no exceptions** - tab bar row must always be `min-height: var(--btn-height)` so the gap to the card never shifts when action buttons appear/disappear - tabs stay on the left in source order + - when a tab has a count, use the same inline badge pattern as task detail tabs: + - `margin-left: 5` + - `font-size: 12` + - `font-weight: 600` + - `background: var(--card-bg-2)` + - `border: 1px solid var(--border)` + - `border-radius: 10px` + - `padding: 3px 8px` + - active tab count uses `var(--accent)` text; inactive uses `var(--text-muted)` - action buttons group sits on the right using: `margin-left: auto`, `display: flex`, `align-items: center`, `gap: 8` - do not use hardcoded spacer blocks (`width` filler divs) to force alignment - icon-only filter/action buttons share the same row and align vertically with add buttons - filter button wrapper: `
` — `display: flex` prevents block stretching that misaligns the button vertically - filter button: `className="btn btn-outline"` + `style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}`, icon only (no label), funnel SVG `` at `13×13`; dropdown uses `site-header-avatar-menu` + `site-header-avatar-item` classes - when the control bar uses a multi-column grid (to align with split-card layouts below), add `align-items: center` to the grid and `min-height: var(--btn-height)` to every column so row height is stable across all tab states + - Tasks/Projects page tab rules: + - default landing tab is `New Requests` + - keep `All Tasks`, `In Progress`, `On Hold`, `In Review`, and `Completed` + - replace the old `To Do` tab with: + - `New Requests` for `not_started` rows on `R00` + - `Revisions` for `not_started` rows on `R01+` + - the project table tabs on the right also use the same count badge pattern ## 7) Dashboard Grids (Team) - Stat row: `grid-template-columns: 1fr 1fr 1fr 1.5fr`, `gap: 24`, `margin-bottom: 0` @@ -112,7 +137,7 @@ This is the single source of truth for dashboard/profile visual structure and UI - Meta/date text: `11px` - Progress track: `height: 4px`, `radius: 2px` - Percentage width slot: `min-width: 28px` -- Empty states in all cards (dashboard, profile, tasks, projects, file sharing, etc.): +- Empty states in all cards (dashboard, profile, tasks, projects, etc.): - any `No ...` message is centered in the card body (`display:flex`, `align-items:center`, `justify-content:center`) - use shared class treatment (`.card-empty-center`) for consistency - avoid top-offset-only placement for empty text @@ -135,8 +160,6 @@ This is the single source of truth for dashboard/profile visual structure and UI - row vertical spacing via cell padding: typically `5px` - Hot Tasks column widths: - check `10%`, task `40%`, requested by `35%`, due by `15%` -- Client Highlight column widths: - - icon `5%`, company `22%`, contact `23%`, projects `13%`, open `13%`, outstanding `12%`, paid `12%` - Sorting rule: - Every visible data column header must be sortable. - Use clickable header controls (`SortTh`) with ascending/descending indicator. @@ -173,57 +196,58 @@ This is the single source of truth for dashboard/profile visual structure and UI - Activity feed includes: - actions performed by the viewed user - `Task approved` and `Task rejected` entries for tasks assigned to that viewed user (even if actioned by someone else) -## 12.5) File Sharing -- Main split: `grid-template-columns: minmax(0, 1fr) minmax(0, 3fr)` for an effective `25 / 75` layout, `gap: 24` -- Left stacked cards (`Pinned`, `Navigation`): `gap: 24` -- Canonical folder structure: - - team and subcontractor profile folders live at `Team/` - - company folders live at `Clients/` - - project folders live at `Clients//Projects/` - - every project auto-creates `00 Project Files` - - task folders live at `Clients//Projects//` - - every task auto-creates `Request Info`, `Working Files`, and `Old Books` - - request submission files are copied into `Request Info/R00`, `R01`, and higher revision folders - - renaming a team user, subcontractor user, company, project, or task must rename the matching filesystem folder - - folder repair/backfill is additive only: create missing folders, leave existing folders untouched - - request-info backfill must skip files that already exist and must not overwrite added working files or user-managed files - - deleting a task in the portal moves its task folder into `Archive/Clients//Projects//` instead of deleting it - - deleting a project in the portal moves its project folder into `Archive/Clients//Projects/` instead of deleting it - - orphan reconciliation may move folders into archive when they no longer map to portal records - - orphan team folders move to `Archive/Team/` when no team or subcontractor profile matches that folder name - - orphan project folders move to `Archive/Clients//Projects/` when no portal project matches that folder name under that company - - orphan reconciliation does not remove company roots or overwrite archived content; it merge-moves into archive -- Access/home rules: - - team members have full file access - - client users with exactly one tied company use that company folder as home: `/Clients/` - - client users tied to multiple companies use `/Clients` as home and only see company folders they are tied to - - `/Clients` acts as a virtual parent for multi-company client users and must not expose unrelated company folders - - subcontractor users use virtual home `/` - - subcontractor virtual home shows `Team` plus only the project folders they are assigned to - - subcontractor users skip all other root folders and must not see unrelated client or internal roots - - subcontractor `Team` maps to the real shared `Team` folder - - each subcontractor project folder is a virtual root that maps to the real client project path `/Clients//Projects/` -- Right card header: - - title label is `File Browser` - - drag/drop note sits directly under the title and uses subtext sizing: `12px` - - current file path uses the same `11px` uppercase label treatment as the title row family - - toolbar does not show `+ Folder` or `Upload` buttons - - upload happens by dragging and dropping files or folders into the right card - - delete action uses the same geometry as the profile edit button (`top: 18px`, `right: 21px`, `8px` radius, `22px` height, `12px` text, `0 12px` padding) but uses danger styling with red hover/fill -- Right card table: - - uses dashboard table styling (`table-layout: fixed`, transparent header row, `10px` uppercase headers, `13px/12px` body text) - - card padding matches dashboard widget shell (`18px 21px`) - - clickable text inside the table highlights in accent; rows do not use block hover fill - - body cells are borderless like dashboard card tables - - body cell padding matches dashboard tables exactly: `5px` - - visible headers (`Name`, `Size`, `Modified`) use shared sortable header controls - - file/folder icon footprint is compact so row height stays on the same cadence as dashboard tables - - alternating rows use a very subtle theme-aware tint for scanability -- Navigation tree: - - `Home` aligns to the first arrow column - - connector line runs through the arrow center column - - pinned folders are user-specific and must not be shared across users on the same device/browser session - - hover is text-only accent color, no row fill + +## 12.25) Shared Portrait Logic +- Shared profile/avatar rendering must use one common resolver path instead of page-specific inline `` fallbacks. +- Canonical shared component: `ProfileAvatar` +- Portrait source resolution order: + - explicit `avatarUrl` prop when already resolved + - `profile.avatar_url` + - profile lookup by `profileId` + - profile lookup by normalized display `name` as a fallback when only sender/actor name exists in derived dashboard data + - initials fallback when no profile image is available +- Shared portrait behavior: + - image uses `object-fit: cover` + - shape is always circular (`border-radius: 50%`) + - fallback initials use accent background with dark text + - layout/header/task/project/dashboard surfaces should reuse the shared portrait resolver instead of hand-rolling image + initials logic + +## 12.3) Team Dashboard Data Logic +- Team dashboard is shared across roles, but card data is role-filtered by scoped tasks/projects/companies. +- Focus refresh + realtime refresh should be wired through the shared live-refresh path, not duplicated per page. +- Scoped company/project/task IDs should be resolved through the shared scope resolver for client/external views. +- Tall dashboard list cards should use viewport-driven height with internal scroll instead of hard-coded row counts: + - measure the lower dashboard row against the current window height and size the cards to the remaining viewport space + - do not use a generic fixed tall-card height that can force whole-page scrolling on shorter windows + - do not apply a lower clamp that stops the card from continuing to compress with the window + - scrolling stays inside the card body, not on the full page layout +- `Team Performance` card logic: + - source data is `deliveries` joined to `submissions` and `tasks` + - each delivery row must include `submission.task_id` plus nested task assignee info + - non-team roles must filter deliveries back down to their scoped task IDs before calculating performance rows + - month dropdown options are derived only from months that actually contain scoped delivery data + - initial render must use the first valid month with data; card must not render empty until the dropdown is manually changed + - performer identity resolves first from `task.assigned_to`, then falls back to normalized `sent_by` / display name matching against profiles + - avatar in `Team Performance` follows shared portrait resolution rules above + - counts: + - `version_number === 0` => `new` + - `version_number > 0` => `revision` + - progress bar percentage is performer completed count divided by total completed count in the active month +- Dashboard lower-left task card logic: + - team view remains `Hot Tasks` + - client view remains `Tasks Ready For Review` + - external/subcontractor view is `My Tasks` + - subcontractor `My Tasks` must source directly from scoped tasks assigned to the current subcontractor, not from `is_hot` submission flags + - subcontractor `My Tasks` should include only active assigned tasks: + - `not_started` + - `in_progress` + - `client_review` + - `on_hold` + - subcontractor `My Tasks` should exclude completed/closed states such as: + - `client_approved` + - `invoiced` + - `paid` + - subcontractor `My Tasks` rows should show task, project, status, and due date ## 13) Radius + Geometry Rules - Dashboard/profile widgets: `8px` radius @@ -250,6 +274,22 @@ This is the single source of truth for dashboard/profile visual structure and UI - light mode: outline labels use light-theme primary text; danger labels use danger text - outline button border uses the same `var(--border)` token as card outlines unless a button role explicitly overrides it - keep existing button role styling intact (`outline`, `primary`, `danger`); geometry changes, not role colors + - popup/modal footer action rows are a no-wrap horizontal row: + - `display: flex` + - `justify-content: flex-end` + - `gap: 8px` + - `flex-wrap: nowrap` + - popup/modal action buttons use a stable minimum width so loading labels do not shove adjacent buttons underneath: + - `min-width: 108px` + - center content horizontally + - request/add-task popups, review/revision/reject/amend popups, and other form submit dialogs should keep the save/submit/cancel buttons on one line while the submit label changes to `Saving...` / `Submitting...` + - status action buttons that change the task state must update the page immediately and lock while the save is in flight so users cannot double-press: + - flip local UI state optimistically + - disable related status buttons until request finishes + - if save fails, roll back local state and show error +- route/page loading should continue using the shared `PageLoader` until page data is actually ready +- avoid plain page-level `Loading...` text fallbacks after navigation, or the popup can disappear while the route is still fetching + - pages with longer initial fetches should pass their route loading state into shared layout/page shells so the popup stays up until all first-load data for that screen is ready - Inputs/dropdowns may use their own control radius unless a page-specific rule says otherwise - Circular elements (avatar/day/icon badges): `50%` @@ -261,7 +301,7 @@ This is the single source of truth for dashboard/profile visual structure and UI ## 14.5) Loading Popup - Use shared `PageLoader` component for loading overlays instead of page-specific popup implementations. -- Loading popups/overlays, including shared site-wide page loaders and file-sharing loading states, use the same widget shell as dashboard cards: +- Loading popups/overlays, including shared site-wide page loaders, use the same widget shell as dashboard cards: - `background: var(--popup-bg)` - `border: 1px solid var(--border)` - `border-radius: 8px` @@ -289,6 +329,13 @@ This is the single source of truth for dashboard/profile visual structure and UI - textarea text: `13px`, color `var(--text-primary)` - field surfaces and borders use global theme tokens (`var(--card-bg-2)` + `var(--border)`) for dark/light parity - modal form action buttons use the standard outline geometry/style (`btn btn-outline`) unless a page explicitly defines a different role +- Finance modal numeric fields (expense amount, invoice money inputs, line-item quantity/rate fields) follow the same shell system instead of custom oversized styles: + - amount/currency field outer shell: `min-height: 42px`, `border-radius: 6px`, `padding: 0 12px`, `background: var(--card-bg-2)`, `border: 1px solid var(--border)` + - currency symbol is separate from the numeric input and uses secondary text + - amount input text: `22px`, `500`, `line-height: 1.1`, right aligned, `font-variant-numeric: tabular-nums` + - line-item numeric inputs keep the normal field feel: `min-height: 32px`, `13px` text + - quantity is centered; rates/prices/totals are right aligned with tabular numerals + - do not use giant red standalone number inputs; red is reserved for negative/danger/read-only emphasis, not default editable money entry ## 14.7) Status Tags - Shared status tags use `StatusBadge` (`.badge.badge-status`). @@ -329,7 +376,7 @@ This is the single source of truth for dashboard/profile visual structure and UI - fast `160ms` - base `220ms` - easing `cubic-bezier(0.22, 1, 0.36, 1)` -- Dropdown animation: `ui-fade-up` from `translateY(4px)` + opacity 0 -> 1 +- Dropdown/menu entrance animation should be minimal; avoid decorative motion when it competes with the shared background spark effect. ## 17) Hover Interaction Contract - Sidebar, header icon buttons, dropdown items, and avatar menu items must show a visible hover surface before click. @@ -341,10 +388,6 @@ This is the single source of truth for dashboard/profile visual structure and UI - Hover and active must be visually distinct: - hover uses stronger temporary contrast (`bg` + thin border), - active remains persistent selected-state background. -- File Sharing left navigation exception: - - `Pinned` + `Navigation` tree rows use text-only hover highlight (`color: var(--accent)`). - - Do not apply row background fill for hover in this section. - ## 17.5) Link Interaction Standard - Use shared link classes only; do not hand-roll page-specific link hover styles: - table/text links inside cards and tables: `table-link` @@ -357,7 +400,7 @@ This is the single source of truth for dashboard/profile visual structure and UI - Do not rely on inline style color overrides for hover behavior; if a variant is needed, add/extend a shared class in `index.css`. ## 16) Non-Negotiable Implementation Rules -- Keep gradient backgrounds on `html`, not `body` +- Keep gradient backgrounds on `body`, not `html` - Keep widget shell values (`18px 21px`, `8px`, blur 12, border token) consistent - Maintain global `24px` spacing rhythm for page/frame/grid gaps - Keep team dashboard card order: @@ -366,9 +409,351 @@ This is the single source of truth for dashboard/profile visual structure and UI 3. Net Profit 4. Revenue (wide) - Keep row 2 order: Recent Activity (left), Tasks In Progress (center), Calendar (right fixed at `280px`) +- Keep the lower dashboard row shared across all three roles: + 1. role-specific task list card + 2. Team Performance +- Do not add an extra team-only `Client Highlight` card under the shared dashboard rows + +## 17) Team Reports +- Team-only tools nav includes `Reports`. +- Reports page is a finance-adjacent audit surface, but separate from the invoice page. +- Primary report source is task-version billing units, not lump tasks: + - `R00` is its own row + - each real revision version (`R01`, `R02`, and so on) is its own row +- Report rows must derive from the same version-unit billing logic as invoice creation: + - initial/new rows map to task-level client invoice units + - revision rows map to submission-level client invoice units + - hidden review-shadow submissions must not create report rows +- Minimum columns: + - company + - project + - task name + - `R##` + - type (`New` / `Revision`) + - version workflow status + - billing status tag (`Not Invoiced`, `Invoiced`, `Paid`) +- Reports page must support filtering by: + - company + - project +- Reports page must support PDF export of the currently filtered rows. ## 18) Invoice Rules - New work (`R00`) is billed at the new-service rate. - Revision `R01` is free. - Revisions `R02+` are billed incrementally: each uninvoiced revision entry contributes exactly `1x` revision charge. - Previously invoiced revisions are excluded; later invoices only charge newly uninvoiced revisions (e.g. if `R02` was already invoiced, at `R04` only `R03` + `R04` are billed). +- Billing is version-unit based, not task-lump based: + - `R00` is one unit + - `R01` is one unit + - `R02+` are separate units +- Team -> client invoice availability: + - a task/version can appear only if it is client approved + - it must not have been invoiced to the client before + - already invoiced or paid client invoice units must not appear again + - revision availability must be sourced per version submission, not blocked just because the task's `R00` was already invoiced + - invoice add-item UI should show a visible type chip for each unit: + - `New` for `R00` + - `Revision` for `R01+` + - version availability is tied to completed version state, not only the task's current active state: + - if a newer version is now active (`not_started`, `in_progress`, etc.), older completed uninvoiced versions must still appear + - example: if `R02` is now active, uninvoiced `R01` may still appear while `R02` must stay off the invoice list until it is completed/approved + - team invoice popup and standalone team invoice page must both use the same shared version-eligibility source, not separate page-local rules + - clicking a team invoice from the `/finances` invoice list should open the same shared invoice-detail popup content used by the standalone team invoice detail route, not a separate simplified modal + - creating a new team invoice should also open that same shared invoice-detail popup after save +- Subcontractor -> Fourge invoice availability: + - a task/version can appear only if that subcontractor actually worked/delivered that version + - it must be client approved + - it must not have been invoiced by that subcontractor to Fourge before + - already invoiced or paid subcontractor invoice units must not appear again +- Client invoice history and subcontractor invoice history are separate streams: + - the same task/version may exist in client billing and subcontractor billing + - duplicate prevention happens within each stream separately + +## 18.0) Request And Task Lifecycle +- A task starts as a request created by `team` or `client`. +- The initial request is version `R00`. +- `R00` request data lives on the initial `submission` row. +- Visible `R##` labels on task lists/cards/detail must use the task's true current version: + - derive from `max(tasks.current_version, submission.version_number)` + - do not derive the visible `R##` from the deadline-bearing submission row + - deadline/request metadata may come from the deadline source submission, but the displayed revision number must still reflect the true current version +- Client and team can amend the current request without creating a new visible revision item: + - amendment stays tied to the same active version + - amendment does not increment `R##` +- Team or subcontractor works the task and places it in review. +- Review submissions belong in the `Submissions` tab and may happen multiple times for the same `R##`. +- Client decision on a review: + - `Approve`: + - task moves to `client_approved` + - task remains on the approved version + - `Reject`: + - task returns to `in_progress` + - same assigned worker stays on the task + - `R##` does not increment by rejection alone + - rejected reason is stored as `Rejected Notes` tied to that same version +- A real revision request creates the next visible version: + - `R00` -> `R01` + - `R01` -> `R02` + - etc. + - when a revision request creates the next `R##`, the task keeps the same last assignee instead of clearing assignment + - status resets to `not_started`, but the assignee remains attached so the work comes back to the same person by default +- Revision requests are separate from rejection notes: + - revision request = new visible request/version item + - rejection note = side note on the current version +- Re-review after rejection must still appear in `Submissions` without creating a fake new visible request/revision row. + +## 18.1) Task File Placement Rules +- Company and project folder chain: + - when a new company is created by team, the portal ensures a matching server folder exists: + - `/Clients/{Company}` + - when a company name is changed, the company folder is renamed to match + - when a new subcontractor is created by team, the portal ensures a matching server folder exists: + - `{FILEBROWSER_SUBS_ROOT}/{Subcontractor Name}` + - when a subcontractor name is changed, that subcontractor folder is renamed to match + - when a new project is created, the portal ensures a matching server folder exists: + - `/Clients/{Company}/Projects/{Project}` + - inside each new project folder, the portal also pre-creates: + - `00 Project Files` + - when a project name is changed, the project folder is renamed to match + - when a new task request is created, the portal ensures a matching server folder exists: + - `/Clients/{Company}/Projects/{Project}/{Task}` + - inside each new task folder, the portal also pre-creates: + - `Old Books` + - `Working Files` + - `Survey` + - folder creation now works via FileBrowser API using: + - a valid production `FILEBROWSER_TOKEN` + - FileBrowser source `srv` + - folder sync uses FileBrowser only for server folder creation + - this does not restore the old file-sharing page or task folder tab +- Initial task request files: + - created by `team` or `client` + - attach to the initial request in the `Overview` tab + - stored on the initial `submission` + - file records live in `submission_files` + - request file flow now works in this order: + - ensure the matching task folder exists under `/Clients/{Company}/Projects/{Project}/{Task}` + - upload each attached file into Supabase bucket `submissions` + - store the file row in `submission_files` + - then mirror that same saved file from Supabase into the server-side `Survey` folder + - initial request files are stored in Supabase under the task's `Survey` path for portal access + - storage path format: + - `{taskId}/Survey/{timestamp}_{originalFileName}` + - initial request files are also mirrored into the server-side task folder: + - `/Clients/{Company}/Projects/{Project}/{Task}/Survey` + - the mirror now copies from the saved Supabase storage object on the server side instead of re-uploading the browser file body + - multiple attached request files are processed one by one through that same mirror flow and should all land in the same `Survey` folder + - the live working behavior is: + - request attachments still show inside the portal from Supabase + - the same attachments also appear in the matching server `Survey` folder + - for `Brand Book` requests, sign details are stored directly on the `submission` row + - sign detail payload lives in `submissions.signs` + - old `submission_signs` rows are migrated into `submissions.signs` and no longer used by the portal + - sign info in the `Overview` tab renders as plain text rows with no inset background card +- Revision request files: + - created by `team` or `client` + - attach to the specific revision entry in the `Revisions` tab + - stored on the revision `submission` + - file records live in `submission_files` +- Review submission files: + - created by `team` or `subcontractor` + - attach to the specific submitted review entry in the `Submissions` tab + - stored on the `delivery` + - file records live in `delivery_files` +- Client rejection notes: + - when client rejects a task in review, it must not create a new request/revision item + - rejection opens a note box + - rejection returns the task to `in_progress` + - rejection keeps the same assigned worker on the task + - rejection does not increment the `R##` version by itself + - note is tied to the exact version being rejected + - `R00` rejection note shows beside the `R00` request info + - `R01` rejection note shows beside the `R01` request info + - `R02` rejection note shows beside the `R02` request info + - label should read `Rejected Notes` + - these notes are separate from normal comments and should not inflate the visible comments count + +## 18.2) Task Notification Rules +- New request: + - when a new request is created, it emails: + - all team members + - this applies from client request flows and team request flows +- In review: + - when a task is placed in review, it emails: + - the client + - all team members + - the assigned user +- On hold: + - when a task is placed on hold, it emails: + - the client + - all team members + - the assigned user +- Amend request: + - when a request is amended, it emails: + - the client + - all team members + - the assigned user +- Rejected: + - when a client rejects a task, it emails: + - the client + - all team members + - the assigned user + - rejected task email uses the shared task-status layout only + - do not also send a separate legacy revision email +- Approved: + - when a client approves a task, it emails: + - the client + - all team members + - the assigned user +- Revision request: + - when a revision request is created, it emails: + - the client + - all team members + - the assigned user + - revision request should send one shared task-status email only + - do not also send any older legacy revision email + +## 19) Finance Flow +- Finance is role-split, not one shared page across all users: + - team finance hub: `/finances` + - client invoice view: `/client-invoices` + - subcontractor invoice view: `/subs-invoices` + - subcontractor PO view: `/my-purchase-orders` +- Finance is shared in concept, but not one universal page component yet: + - team sees the full finance hub + - client sees receive/view/pay invoice flow only + - subcontractor sees invoice-Fourge and PO-review flow only + +### 19.1) Team -> Client Invoice Flow +- Team creates invoice for the client. +- Team invoice availability is version-unit based: + - `R00` can bill as new work + - `R01` is tracked but free + - `R02+` can bill as revision units +- A client invoice unit may appear only if: + - it is client approved + - it has not already been invoiced to the client +- Team sends invoice to the client. +- Invoice send email goes to: + - the client recipient + - all team members +- Client does not create invoices back to Fourge in the portal. +- Client only receives, views, pays, and downloads invoice records. +- Client payment can happen: + - through Stripe / portal pay flow + - outside the portal, then team marks the invoice paid manually +- Once paid: + - invoice status is `paid` + - branded paid/receipt email goes to: + - the client recipient + - all team members + - receipt can be generated/downloaded/sent if needed + - paid client invoices must not be deletable by client or team +- Team invoice detail remains the control point for: + - send / resend invoice + - mark paid + - generate invoice PDF + - generate/send receipt + +### 19.2) Client Finance Role +- Clients only see invoices relevant to their company access. +- Clients can: + - view invoices + - filter invoices by company when tied to multiple companies + - download invoice PDFs +- Clients cannot: + - create invoices to Fourge + - manage expenses + - manage subcontractor invoices + - manage POs + +### 19.3) Subcontractor -> Fourge Invoice Flow +- Subcontractor creates invoice to bill Fourge for completed work. +- Subcontractor billing is version-based, not task-lump based: + - `R00` is one billable unit + - `R01` is one version unit but free + - `R02+` are version units and billable +- Subcontractor invoice create page builds available rows from actual delivered versions tied to that subcontractor's assigned tasks. +- Only versions the subcontractor actually delivered should appear as available invoice rows. +- Subcontractor invoice availability must dedupe per `task + version`. +- If a subcontractor invoice already includes a specific version, that same version must not show up again in later subcontractor invoices. +- A subcontractor task/version is available only when: + - it is client approved + - that subcontractor delivered/worked that version + - it has not already been invoiced to Fourge by that subcontractor +- Version availability follows the same older-completed-version rule as team client invoices: + - if a newer version is now active (`not_started`, `in_progress`, etc.), older completed uninvoiced versions must still appear + - example: if `R02` is now active, uninvoiced `R01` may still appear while `R02` must stay off the subcontractor invoice list until it is completed/approved + - subcontractor invoice create page must use that same shared version-eligibility source as the team invoice flows +- That invoice appears to team finance for review/payment. +- Team marks subcontractor invoices `paid`. +- Subcontractors do not normally mark their own invoices paid. +- Once paid: + - subcontractor sees paid status + - branded paid/receipt email goes to: + - that subcontractor + - all team members + - receipt can be generated/downloaded/sent if needed +- Client invoicing and subcontractor invoicing are separate streams: + - team can invoice the client for a task/version + - subcontractor can invoice Fourge for that same task/version +- Client invoice state must not block subcontractor invoice availability by itself. +- Subcontractor invoice state only blocks the same subcontractor task/version from being billed again. +- Subcontractor finance page `/subs-invoices` stays separate from the team finance hub: + - no chart on this page + - top row uses four stat cards only: completed new tasks, completed revisions, invoiced, paid + - invoice history sits below in one full-width card with no secondary right-side companion card + - the `+ Invoice` action uses the same shared popup shell/button treatment as the team finance invoice modal + - subcontractor invoice modal keeps subcontractor-specific fields and billing rules, but visually follows the shared finance popup pattern + - clicking a subcontractor invoice from the list should open the shared invoice detail in a popup, matching the team-side subcontractor invoice interaction + - after a subcontractor creates an invoice, the portal should open that same popup detail view instead of routing away to a separate detail page + - subcontractor invoice popup layout should match the team-side subcontractor invoice popup layout + - subcontractor invoice popup action row should include: + - `Download Invoice` + - `Submit to Team` when still draft + - `Download Receipt` when paid + - subcontractor invoice popup line-item table should include: + - `Work` (`New` or `Revision`) + - `R#` (`R00`, `R01`, and so on) + - `Description` + - `Type` (task service type such as `Brand Book`, `Sign Family`, and so on) + - `Qty` + - `Unit Price` + - `Amount` + - subcontractor invoice detail uses one shared layout across team and subcontractor roles, with role-specific actions loaded into the same detail shell + - shared subcontractor invoice detail pattern: + - uses the shared site header chevron/back pattern from task detail, not a separate page-local header block + - content starts immediately under that shared header using the same task-detail rhythm, with no separate action bar floating above the first card row + - header title styling follows the global site header spec: + - `28px` + - `500` + - `var(--text-primary)` + - `line-height: 1.2` + - header subtitle styling follows the global site header spec: + - `13px` + - `var(--text-muted)` + - two top summary cards are the first content row, using the same `24px` gap rhythm as task detail + - role-specific invoice actions sit inside the top-right summary card header instead of on a separate row + - summary and section labels use widget-title styling: + - `11px` + - `500` + - uppercase + - `letter-spacing: 0.8px` + - `var(--text-secondary)` + - line items live in one full-width card with sticky table header and total footer + - notes card sits below line items when notes exist + +### 19.4) Team -> Subcontractor PO Flow +- Team creates purchase order for subcontractor work. +- PO can optionally be tied to a project and task-backed line items. +- PO email sending is currently disabled. +- Subcontractor can approve the PO from their portal view. +- PO approval is separate from subcontractor invoice payment. +- A PO being approved does not itself mean an invoice has been paid. + +### 19.5) Simple Mental Model +- Fourge -> Client = invoice out +- Client -> Fourge = payment in +- Subcontractor -> Fourge = invoice in +- Fourge -> Subcontractor = payment out +- Fourge -> Subcontractor before payment = PO / work authorization diff --git a/scripts/backfill-project-files-folder.mjs b/scripts/backfill-project-files-folder.mjs deleted file mode 100644 index 0e8afa9..0000000 --- a/scripts/backfill-project-files-folder.mjs +++ /dev/null @@ -1,83 +0,0 @@ -// One-time: creates 00 Project Files folder inside every existing project folder in FileBrowser -// Run: node scripts/backfill-project-files-folder.mjs -import { readFileSync } from 'fs'; -import { resolve, dirname } from 'path'; -import { fileURLToPath } from 'url'; -import { createClient } from '@supabase/supabase-js'; - -const __dir = dirname(fileURLToPath(import.meta.url)); -const envFile = resolve(__dir, '../.env.backfill'); - -const env = {}; -readFileSync(envFile, 'utf8').split('\n').forEach(line => { - const m = line.match(/^([^#=]+)=(.*)$/); - if (m) env[m[1].trim()] = m[2].trim().replace(/^["']|["']$/g, ''); -}); - -const SUPABASE_URL = env.VITE_SUPABASE_URL || env.SUPABASE_URL; -const SERVICE_ROLE_KEY = env.SUPABASE_SERVICE_ROLE_KEY; -const FB_URL = (env.FILEBROWSER_URL || '').replace(/\/+$/, ''); -const FB_TOKEN = env.FILEBROWSER_TOKEN; -const CLIENT_ROOT = env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients'; -const FB_SOURCE = 'files'; - -if (!SUPABASE_URL || !SERVICE_ROLE_KEY) { console.error('Missing Supabase env'); process.exit(1); } -if (!FB_URL || !FB_TOKEN) { console.error('Missing FileBrowser env'); process.exit(1); } - -const admin = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, { auth: { persistSession: false } }); - -function safeName(v) { - return String(v || '').trim() - .replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-') - .replace(/\s+/g, ' ') - .replace(/^-+|-+$/g, ''); -} - -function joinPath(...parts) { - const raw = parts.join('/'); - const clean = raw.split('/').filter(p => p && p !== '.'); - return `/${clean.join('/')}`; -} - -async function mkdir(path) { - const qs = new URLSearchParams({ source: FB_SOURCE, path, isDir: 'true' }).toString(); - const res = await fetch(`${FB_URL}/api/resources?${qs}`, { - method: 'POST', - headers: { Authorization: `Bearer ${FB_TOKEN}` }, - }); - if (!res.ok) { - const text = await res.text(); - if (!text.includes('already') && !text.includes('exist')) { - console.warn(` mkdir ${path}: ${res.status} ${text.slice(0, 80)}`); - } - } -} - -async function main() { - const { data: projects, error } = await admin - .from('projects') - .select('id, name, company:companies(name)'); - - if (error) { console.error('Query failed:', error.message); process.exit(1); } - - console.log(`Found ${projects.length} projects`); - - for (const p of projects) { - const company = safeName(p.company?.name || ''); - const project = safeName(p.name || ''); - if (!company || !project) { console.log(` Skipping (missing name): ${p.id}`); continue; } - - const projectDir = joinPath(CLIENT_ROOT, company, 'Projects', project); - const targetDir = joinPath(projectDir, '00 Project Files'); - - await mkdir(joinPath(CLIENT_ROOT, company)); - await mkdir(joinPath(CLIENT_ROOT, company, 'Projects')); - await mkdir(projectDir); - await mkdir(targetDir); - console.log(` ✓ ${company} / ${project} / 00 Project Files`); - } - - console.log('\nDone.'); -} - -main().catch(err => { console.error(err); process.exit(1); }); diff --git a/scripts/backfill-project-folders.mjs b/scripts/backfill-project-folders.mjs deleted file mode 100644 index daac131..0000000 --- a/scripts/backfill-project-folders.mjs +++ /dev/null @@ -1,127 +0,0 @@ -#!/usr/bin/env node -// Backfill FileBrowser folders for all existing projects and tasks. -// Project: Clients/{company}/Projects/{project}/00 Project Files/ + 00 Project Info/ -// Task: Clients/{company}/Projects/{project}/{task}/Working Files/ + Request Info/ -// Run: node --env-file=.env.backfill scripts/backfill-project-folders.mjs - -const FB_SOURCE = 'files'; -const FILEBROWSER_URL = (process.env.FILEBROWSER_URL || 'https://fourgebranding.krao.us').replace(/\/+$/, ''); -const FILEBROWSER_TOKEN = process.env.FILEBROWSER_TOKEN; -const CLIENT_ROOT = process.env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients'; -const SUPABASE_URL = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL; -const SUPABASE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY; - -if (!FILEBROWSER_TOKEN) { console.error('Missing FILEBROWSER_TOKEN'); process.exit(1); } -if (!SUPABASE_URL || !SUPABASE_KEY) { console.error('Missing Supabase env'); process.exit(1); } - -function safeName(value) { - return String(value || '') - .trim() - .replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-') - .replace(/\s+/g, ' ') - .replace(/^-+|-+$/g, ''); -} - -function normalizePath(path) { - const parts = String(path || '/').split('/').filter(p => p && p !== '.' && p !== '..'); - return `/${parts.join('/')}`; -} - -function joinPath(...parts) { - return normalizePath(parts.join('/')); -} - -async function mkdir(path) { - const qs = new URLSearchParams({ source: FB_SOURCE, path, isDir: 'true' }).toString(); - const res = await fetch(`${FILEBROWSER_URL}/api/resources?${qs}`, { - method: 'POST', - headers: { Authorization: `Bearer ${FILEBROWSER_TOKEN}` }, - }); - // 200 = created, 409 = already exists — both fine - if (!res.ok && res.status !== 409) { - const text = await res.text(); - throw new Error(`mkdir ${path} failed (${res.status}): ${text}`); - } - return res.status; -} - -async function supabaseFetch(path) { - const res = await fetch(`${SUPABASE_URL}/rest/v1/${path}`, { - headers: { - apikey: SUPABASE_KEY, - Authorization: `Bearer ${SUPABASE_KEY}`, - }, - }); - if (!res.ok) throw new Error(`Supabase ${path}: ${await res.text()}`); - return res.json(); -} - -async function run() { - console.log('Fetching projects from Supabase...'); - const projects = await supabaseFetch('projects?select=id,name,company:companies(name)&order=created_at.asc'); - console.log(`Found ${projects.length} projects.`); - - console.log('Fetching tasks from Supabase...'); - const tasks = await supabaseFetch('tasks?select=id,title,project:projects(name,company:companies(name))&order=submitted_at.asc'); - console.log(`Found ${tasks.length} tasks.\n`); - - let created = 0; - let existing = 0; - let errors = 0; - - // ── Projects ────────────────────────────────────────────────────────────── - console.log('=== PROJECTS ==='); - for (const project of projects) { - const companyName = project.company?.name; - if (!companyName) { console.log(` SKIP ${project.name} — no company`); continue; } - - const companyDir = joinPath(CLIENT_ROOT, safeName(companyName)); - const projectsDir = joinPath(companyDir, 'Projects'); - const projectDir = joinPath(projectsDir, safeName(project.name)); - - try { - await mkdir(companyDir); - await mkdir(projectsDir); - const s = await mkdir(projectDir); - await mkdir(joinPath(projectDir, '00 Project Files')); - await mkdir(joinPath(projectDir, '00 Project Info')); - - if (s === 409) { console.log(` EXISTS ${companyName} / ${project.name}`); existing++; } - else { console.log(` CREATED ${companyName} / ${project.name}`); created++; } - } catch (err) { - console.error(` ERROR ${companyName} / ${project.name}: ${err.message}`); - errors++; - } - } - - // ── Tasks ───────────────────────────────────────────────────────────────── - console.log('\n=== TASKS ==='); - for (const task of tasks) { - const projectName = task.project?.name; - const companyName = task.project?.company?.name; - if (!projectName || !companyName) { console.log(` SKIP ${task.title} — missing project/company`); continue; } - - const projectDir = joinPath(CLIENT_ROOT, safeName(companyName), 'Projects', safeName(projectName)); - const taskDir = joinPath(projectDir, safeName(task.title)); - - try { - // Ensure parent exists (idempotent) - await mkdir(joinPath(CLIENT_ROOT, safeName(companyName))); - await mkdir(joinPath(CLIENT_ROOT, safeName(companyName), 'Projects')); - await mkdir(projectDir); - const s = await mkdir(taskDir); - await mkdir(joinPath(taskDir, 'Working Files')); - await mkdir(joinPath(taskDir, 'Request Info')); - - if (s === 409) { console.log(` EXISTS ${companyName} / ${projectName} / ${task.title}`); existing++; } - else { console.log(` CREATED ${companyName} / ${projectName} / ${task.title}`); created++; } - } catch (err) { - console.error(` ERROR ${companyName} / ${projectName} / ${task.title}: ${err.message}`); - errors++; - } - } - - console.log(`\nDone. Created: ${created} Already existed: ${existing} Errors: ${errors}`); -} - -run().catch(err => { console.error(err); process.exit(1); }); diff --git a/scripts/backfill-request-files.mjs b/scripts/backfill-request-files.mjs deleted file mode 100644 index c56851b..0000000 --- a/scripts/backfill-request-files.mjs +++ /dev/null @@ -1,212 +0,0 @@ -// One-time script: copies existing submission files from Supabase Storage to FileBrowser -// Run: node scripts/backfill-request-files.mjs -import { readFileSync } from 'fs'; -import { createClient } from '@supabase/supabase-js'; -import { resolve, dirname } from 'path'; -import { fileURLToPath } from 'url'; - -const __dir = dirname(fileURLToPath(import.meta.url)); -const envFile = resolve(__dir, '../.env.backfill'); - -// Parse env file -const env = {}; -readFileSync(envFile, 'utf8').split('\n').forEach(line => { - const m = line.match(/^([^#=]+)=(.*)$/); - if (m) env[m[1].trim()] = m[2].trim().replace(/^["']|["']$/g, ''); -}); - -const SUPABASE_URL = env.VITE_SUPABASE_URL || env.SUPABASE_URL; -const SERVICE_ROLE_KEY = env.SUPABASE_SERVICE_ROLE_KEY; -const FB_URL = (env.FILEBROWSER_URL || '').replace(/\/+$/, ''); -const FB_TOKEN = env.FILEBROWSER_TOKEN; -const CLIENT_ROOT = env.FILEBROWSER_CLIENT_ROOT || '/Clients'; -const FB_SOURCE = 'srv'; - -if (!SUPABASE_URL || !SERVICE_ROLE_KEY) { console.error('Missing Supabase env'); process.exit(1); } -if (!FB_URL || !FB_TOKEN) { console.error('Missing FileBrowser env'); process.exit(1); } - -const admin = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, { - auth: { persistSession: false, autoRefreshToken: false }, -}); - -function normalizePath(path) { - const parts = String(path || '/').trim().split('/').filter(Boolean); - const clean = []; - for (const p of parts) { - if (p === '.') continue; - if (p === '..') throw new Error('path traversal'); - clean.push(p); - } - return `/${clean.join('/')}`; -} - -function joinPath(...parts) { return normalizePath(parts.join('/')); } - -function safeName(v) { - return String(v || '').trim() - .replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-') - .replace(/\s+/g, ' ') - .replace(/^-+|-+$/g, ''); -} - -async function fbFetch(method, endpoint, { params = {}, headers = {}, body } = {}) { - const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString(); - const res = await fetch(`${FB_URL}${endpoint}?${qs}`, { - method, - headers: { Authorization: `Bearer ${FB_TOKEN}`, ...headers }, - body, - }); - if (!res.ok) { - const text = await res.text(); - throw new Error(`FB ${res.status}: ${text.slice(0, 200)}`); - } - return res; -} - -async function mkdir(path) { - await fbFetch('POST', '/api/resources', { params: { path, isDir: 'true' } }).catch(() => {}); -} - -async function fbExists(path) { - try { - await fbFetch('GET', '/api/resources', { params: { path } }); - return true; - } catch (e) { - if (String(e.message || '').includes('404')) return false; - throw e; - } -} - -async function ensureTaskStructure(companyName, projectName, taskTitle) { - const companyDir = joinPath(CLIENT_ROOT, safeName(companyName)); - const projectsDir = joinPath(companyDir, 'Projects'); - const projectDir = joinPath(projectsDir, safeName(projectName)); - const taskDir = joinPath(projectDir, safeName(taskTitle)); - const requestInfoDir = joinPath(taskDir, 'Request Info'); - const workingFilesDir = joinPath(taskDir, 'Working Files'); - const oldBooksDir = joinPath(taskDir, 'Old Books'); - - await mkdir(companyDir); - await mkdir(projectsDir); - await mkdir(projectDir); - await mkdir(joinPath(projectDir, '00 Project Files')); - await mkdir(taskDir); - await mkdir(requestInfoDir); - await mkdir(workingFilesDir); - await mkdir(oldBooksDir); - - return { requestInfoDir }; -} - -async function main() { - const { data: tasks, error: taskError } = await admin - .from('tasks') - .select(` - id, title, - project:projects!inner( - id, name, - company:companies!inner(name) - ) - `); - - if (taskError) { console.error('Task query failed:', taskError.message); process.exit(1); } - - let ensuredTasks = 0; - for (const task of tasks || []) { - const project = task?.project; - const company = project?.company; - if (!task?.title || !project?.name || !company?.name) continue; - await ensureTaskStructure(company.name, project.name, task.title); - ensuredTasks++; - } - - const { data: rows, error } = await admin - .from('submission_files') - .select(` - id, name, storage_path, - submission:submissions!inner( - id, version_number, - task:tasks!inner( - id, title, - project:projects!inner( - id, name, - company:companies!inner(name) - ) - ) - ) - `); - - if (error) { console.error('Query failed:', error.message); process.exit(1); } - - // Group by task + version - const groups = new Map(); - for (const row of rows || []) { - const sub = row.submission; - const task = sub?.task; - const project = task?.project; - const company = project?.company; - if (!task || !project || !company) continue; - - const key = `${task.id}::${sub.version_number}`; - if (!groups.has(key)) { - groups.set(key, { - companyName: company.name, - projectName: project.name, - taskTitle: task.title, - versionNumber: sub.version_number, - files: [], - }); - } - groups.get(key).files.push({ name: row.name, storage_path: row.storage_path }); - } - - console.log(`Ensured structure for ${ensuredTasks} tasks`); - console.log(`Found ${groups.size} task/revision groups, ${rows.length} total files`); - - let processed = 0, skipped = 0, errors = 0; - - for (const [key, group] of groups) { - if (group.files.length === 0) { skipped++; continue; } - - const revFolder = `R${String(group.versionNumber).padStart(2, '0')}`; - const { requestInfoDir } = await ensureTaskStructure(group.companyName, group.projectName, group.taskTitle); - const revDir = joinPath(requestInfoDir, revFolder); - - console.log(`\n[${group.companyName}] ${group.projectName} / ${group.taskTitle} / ${revFolder} (${group.files.length} files)`); - - await mkdir(revDir); - - for (const file of group.files) { - try { - const { data: blob, error: downloadErr } = await admin.storage - .from('submissions') - .download(file.storage_path); - if (downloadErr || !blob) throw new Error(`download failed: ${downloadErr?.message}`); - const fileBuffer = await blob.arrayBuffer(); - const form = new FormData(); - form.append('file', new Blob([fileBuffer]), file.name); - - const fbFilePath = joinPath(revDir, file.name); - if (await fbExists(fbFilePath)) { - console.log(` - skip existing ${file.name}`); - skipped++; - continue; - } - await fbFetch('POST', '/api/resources', { - params: { path: fbFilePath }, - body: form, - }); - - console.log(` ✓ ${file.name}`); - processed++; - } catch (err) { - console.error(` ✗ ${file.name}: ${err.message}`); - errors++; - } - } - } - - console.log(`\nDone. Processed: ${processed}, Skipped: ${skipped}, Errors: ${errors}`); -} - -main().catch(err => { console.error(err); process.exit(1); }); diff --git a/scripts/cleanup-old-project-files-folders.mjs b/scripts/cleanup-old-project-files-folders.mjs deleted file mode 100644 index 67053a4..0000000 --- a/scripts/cleanup-old-project-files-folders.mjs +++ /dev/null @@ -1,103 +0,0 @@ -// One-time: removes leftover '.Project Files' and 'Project Files' folders -// that may still exist alongside the renamed '00 Project Files' -// Run: node scripts/cleanup-old-project-files-folders.mjs -import { readFileSync } from 'fs'; -import { resolve, dirname } from 'path'; -import { fileURLToPath } from 'url'; -import { createClient } from '@supabase/supabase-js'; - -const __dir = dirname(fileURLToPath(import.meta.url)); -const envFile = resolve(__dir, '../.env.backfill'); - -const env = {}; -readFileSync(envFile, 'utf8').split('\n').forEach(line => { - const m = line.match(/^([^#=]+)=(.*)$/); - if (m) env[m[1].trim()] = m[2].trim().replace(/^["']|["']$/g, ''); -}); - -const SUPABASE_URL = env.VITE_SUPABASE_URL || env.SUPABASE_URL; -const SERVICE_ROLE_KEY = env.SUPABASE_SERVICE_ROLE_KEY; -const FB_URL = (env.FILEBROWSER_URL || '').replace(/\/+$/, ''); -const FB_TOKEN = env.FILEBROWSER_TOKEN; -const CLIENT_ROOT = env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients'; -const FB_SOURCE = 'files'; - -if (!SUPABASE_URL || !SERVICE_ROLE_KEY) { console.error('Missing Supabase env'); process.exit(1); } -if (!FB_URL || !FB_TOKEN) { console.error('Missing FileBrowser env'); process.exit(1); } - -const admin = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, { auth: { persistSession: false } }); - -function safeName(v) { - return String(v || '').trim() - .replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-') - .replace(/\s+/g, ' ') - .replace(/^-+|-+$/g, ''); -} - -function joinPath(...parts) { - const clean = parts.join('/').split('/').filter(p => p && p !== '.'); - return `/${clean.join('/')}`; -} - -async function fbList(path) { - const qs = new URLSearchParams({ source: FB_SOURCE, path }).toString(); - const res = await fetch(`${FB_URL}/api/resources?${qs}`, { - headers: { Authorization: `Bearer ${FB_TOKEN}` }, - }); - if (!res.ok) return null; - const data = await res.json().catch(() => null); - return data?.folders || []; -} - -async function fbDelete(path) { - const qs = new URLSearchParams({ source: FB_SOURCE, path }).toString(); - const res = await fetch(`${FB_URL}/api/resources?${qs}`, { - method: 'DELETE', - headers: { Authorization: `Bearer ${FB_TOKEN}` }, - }); - if (!res.ok) { - const text = await res.text(); - throw new Error(`${res.status}: ${text.slice(0, 100)}`); - } -} - -const OLD_NAMES = ['.Project Files', 'Project Files']; - -async function main() { - const { data: projects, error } = await admin - .from('projects') - .select('id, name, company:companies(name)'); - - if (error) { console.error('Query failed:', error.message); process.exit(1); } - console.log(`Checking ${projects.length} projects...\n`); - - let cleaned = 0; - - for (const p of projects) { - const company = safeName(p.company?.name || ''); - const project = safeName(p.name || ''); - if (!company || !project) continue; - - const projectDir = joinPath(CLIENT_ROOT, company, 'Projects', project); - const entries = await fbList(projectDir); - if (!entries) { console.log(` ? could not list: ${company} / ${project}`); continue; } - - const names = entries.map(e => e.name); - for (const oldName of OLD_NAMES) { - if (names.includes(oldName)) { - const oldPath = joinPath(projectDir, oldName); - try { - await fbDelete(oldPath); - console.log(` ✓ deleted "${oldName}": ${company} / ${project}`); - cleaned++; - } catch (err) { - console.error(` ✗ failed to delete "${oldName}" in ${company} / ${project}: ${err.message}`); - } - } - } - } - - console.log(`\nDone. Cleaned ${cleaned} old folder(s).`); -} - -main().catch(err => { console.error(err); process.exit(1); }); diff --git a/scripts/generate-task-pdf.html b/scripts/generate-task-pdf.html new file mode 100644 index 0000000..bffee65 --- /dev/null +++ b/scripts/generate-task-pdf.html @@ -0,0 +1,180 @@ + + + + + Task Tracker PDF Generator + + + + + + +
Task Tracker PDF
+ + +
Ready
+ + + + diff --git a/scripts/rename-project-files-folder.mjs b/scripts/rename-project-files-folder.mjs deleted file mode 100644 index 655cad8..0000000 --- a/scripts/rename-project-files-folder.mjs +++ /dev/null @@ -1,104 +0,0 @@ -// One-time: renames .00 Project Files → 00 Project Files for all existing project folders -// Run: node scripts/rename-project-files-folder.mjs -import { readFileSync } from 'fs'; -import { resolve, dirname } from 'path'; -import { fileURLToPath } from 'url'; -import { createClient } from '@supabase/supabase-js'; - -const __dir = dirname(fileURLToPath(import.meta.url)); -const envFile = resolve(__dir, '../.env.backfill'); - -const env = {}; -readFileSync(envFile, 'utf8').split('\n').forEach(line => { - const m = line.match(/^([^#=]+)=(.*)$/); - if (m) env[m[1].trim()] = m[2].trim().replace(/^["']|["']$/g, ''); -}); - -const SUPABASE_URL = env.VITE_SUPABASE_URL || env.SUPABASE_URL; -const SERVICE_ROLE_KEY = env.SUPABASE_SERVICE_ROLE_KEY; -const FB_URL = (env.FILEBROWSER_URL || '').replace(/\/+$/, ''); -const FB_TOKEN = env.FILEBROWSER_TOKEN; -const CLIENT_ROOT = env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients'; -const FB_SOURCE = 'files'; - -if (!SUPABASE_URL || !SERVICE_ROLE_KEY) { console.error('Missing Supabase env'); process.exit(1); } -if (!FB_URL || !FB_TOKEN) { console.error('Missing FileBrowser env'); process.exit(1); } - -const admin = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, { auth: { persistSession: false } }); - -function safeName(v) { - return String(v || '').trim() - .replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-') - .replace(/\s+/g, ' ') - .replace(/^-+|-+$/g, ''); -} - -function joinPath(...parts) { - const clean = parts.join('/').split('/').filter(p => p && p !== '.'); - return `/${clean.join('/')}`; -} - -async function fbRename(fromPath, toPath) { - const qs = new URLSearchParams({ source: FB_SOURCE }).toString(); - const res = await fetch(`${FB_URL}/api/resources?${qs}`, { - method: 'PATCH', - headers: { Authorization: `Bearer ${FB_TOKEN}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ - action: 'rename', - items: [{ fromSource: FB_SOURCE, fromPath, toSource: FB_SOURCE, toPath }], - overwrite: false, - }), - }); - if (!res.ok) { - const text = await res.text(); - throw new Error(`${res.status}: ${text.slice(0, 120)}`); - } -} - -async function fbMkdir(path) { - const qs = new URLSearchParams({ source: FB_SOURCE, path, isDir: 'true' }).toString(); - const res = await fetch(`${FB_URL}/api/resources?${qs}`, { - method: 'POST', - headers: { Authorization: `Bearer ${FB_TOKEN}` }, - }); - if (!res.ok) { - const text = await res.text(); - if (!text.includes('already') && !text.includes('exist')) throw new Error(`mkdir ${path}: ${res.status}`); - } -} - -async function main() { - const { data: projects, error } = await admin - .from('projects') - .select('id, name, company:companies(name)'); - - if (error) { console.error('Query failed:', error.message); process.exit(1); } - console.log(`Found ${projects.length} projects`); - - for (const p of projects) { - const company = safeName(p.company?.name || ''); - const project = safeName(p.name || ''); - if (!company || !project) continue; - - const projectDir = joinPath(CLIENT_ROOT, company, 'Projects', project); - const oldPath = joinPath(projectDir, 'Project Files'); - const newPath = joinPath(projectDir, '00 Project Files'); - - try { - await fbRename(oldPath, newPath); - console.log(` ✓ renamed: ${company} / ${project}`); - } catch (err) { - // If rename fails (source doesn't exist), ensure new folder exists - try { - await fbMkdir(newPath); - console.log(` + created: ${company} / ${project} (no dot folder found)`); - } catch (e2) { - console.log(` ~ exists: ${company} / ${project}`); - } - } - } - - console.log('\nDone.'); -} - -main().catch(err => { console.error(err); process.exit(1); }); diff --git a/src/App.jsx b/src/App.jsx index 9fe1851..a25b23f 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -52,8 +52,8 @@ const TeamSubInvoiceDetail = lazy(() => import('./pages/team/TeamSubInvoiceDetai const SurveyMaker = lazy(() => import('./pages/SurveyMaker')); const BrandBook = lazy(() => import('./pages/BrandBook')); const Converters = lazy(() => import('./pages/Converters')); -const FileSharing = lazy(() => import('./pages/FileSharing')); const TeamFourgePasswords = lazy(() => import('./pages/team/TeamFourgePasswords')); +const TeamReports = lazy(() => import('./pages/team/TeamReports')); const ExternalMyPurchaseOrders = lazy(() => import('./pages/external/ExternalMyPurchaseOrders')); const ExternalMyInvoices = lazy(() => import('./pages/external/ExternalMyInvoices')); const ExternalMyInvoiceDetail = lazy(() => import('./pages/external/ExternalMyInvoiceDetail')); @@ -73,6 +73,16 @@ function RedirectToTask() { return ; } +function RedirectTeamInvoiceDetail() { + const { id } = useParams(); + return ; +} + +function RedirectSubsInvoiceDetail() { + const { id } = useParams(); + return ; +} + function NavigateCompanyDetail() { const { id } = useParams(); return ; @@ -100,25 +110,30 @@ export default function App() { } /> } /> } /> - } /> - } /> + } /> + } /> } /> - } /> + } /> } /> } /> + } /> + } /> + } /> } /> } /> } /> - } /> - } /> } /> + } /> } /> } /> } /> } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> } /> @@ -130,7 +145,8 @@ export default function App() { } /> } /> } /> - } /> + } /> + } /> } /> } /> diff --git a/src/components/FileAttachment.jsx b/src/components/FileAttachment.jsx index c616eab..e665990 100644 --- a/src/components/FileAttachment.jsx +++ b/src/components/FileAttachment.jsx @@ -1,4 +1,4 @@ -import { useState, useRef } from 'react'; +import { useId, useRef, useState } from 'react'; const MAX_FILES = 20; const MAX_SIZE_MB = 250; @@ -10,6 +10,7 @@ const formatSize = (bytes) => { }; export default function FileAttachment({ files, onChange }) { + const inputId = useId(); const [errors, setErrors] = useState([]); const [dragging, setDragging] = useState(false); const dragCounter = useRef(0); @@ -79,8 +80,8 @@ export default function FileAttachment({ files, onChange }) { transition: 'all 160ms', cursor: 'pointer', }} > - -