fix: crash bugs in TeamInvoices + faster load

Bug fixes:
- TeamInvoices: add useAuth import/currentUser (invoice-create crash)
- TeamInvoices: setChartYear -> setExportYear (year-dropdown crash)
- TeamDashboard: drop redundant setState-in-effect (cascading renders)
- Companies: delete dead _UnusedClientCompanies (illegal hook calls)
- annotate intentional empty PDF-fallback catches

Load speed:
- preconnect/dns-prefetch to Supabase origin
- lazy-load heic-to in Converters: page chunk 2737KB -> 9KB
- split recharts into its own 'charts' vendor chunk

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Krao Hasanee
2026-06-08 12:59:11 -04:00
parent 85625f4d95
commit 04e0911e9f
101 changed files with 11786 additions and 7445 deletions
-13
View File
@@ -1,13 +0,0 @@
{
"extraKnownMarketplaces": {
"ecc": {
"source": {
"source": "github",
"repo": "affaan-m/ecc"
}
}
},
"enabledPlugins": {
"ecc@ecc": true
}
}
+9 -70
View File
@@ -1,77 +1,16 @@
{ {
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(*)", "Bash(npm run *)",
"Bash(\"/Users/kraohasanee/Documents/40-49 Fourge:*)", "Bash(awk 'NR>=547 && /^}/ {print NR\": \"$0; c++; if\\(c>=1\\) exit}' src/pages/Companies.jsx)",
"Bash(vercel --version)", "Bash(sed -n '545,548p' src/pages/Companies.jsx)",
"Bash(vercel --prod)", "Bash(sed -i '' '545,715d' src/pages/Companies.jsx)",
"Bash(supabase --version)", "Bash(sed -n '542,548p' src/pages/Companies.jsx)",
"Bash(brew install:*)", "Bash(sed -n '76,80p' src/pages/team/TeamSubInvoiceDetail.jsx)",
"Read(//usr/local/bin/**)", "Bash(sed -n '147p' src/pages/Profile.jsx)",
"Read(//Users/kraohasanee/.local/bin/**)", "Bash(awk '{print $5, $9}')",
"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(git add *)", "Bash(git add *)",
"Bash(git commit -m ' *)", "Bash(git commit -q -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"
] ]
} }
} }
-126
View File
@@ -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 `<StatusBadge status={...} />` — 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
<Layout>
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{/* fixed sections: flexShrink: 0 */}
{/* filling section: flex: 1, minHeight: 0 */}
</div>
</Layout>
```
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)
-212
View File
@@ -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 });
}
-222
View File
@@ -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 });
}
-145
View File
@@ -1,130 +1,5 @@
import { createClient } from '@supabase/supabase-js'; 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) { export default async function handler(req, res) {
if (req.method !== 'DELETE') return res.status(405).json({ error: 'Method not allowed' }); 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 }, 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') { if (profile.role === 'client') {
const { data: proj, error: projErr } = await callerClient.from('projects').select('id').eq('id', projectId).single(); 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' }); 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 { data: tasks } = await admin.from('tasks').select('id').eq('project_id', projectId);
const taskIds = (tasks || []).map(t => t.id); 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); const { error } = await admin.from('projects').delete().eq('id', projectId);
if (error) return res.status(500).json({ error: error.message }); 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 }); return res.status(200).json({ ok: true });
} }
+2 -140
View File
@@ -1,126 +1,5 @@
import { createClient } from '@supabase/supabase-js'; 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) { export default async function handler(req, res) {
if (req.method !== 'DELETE') return res.status(405).json({ error: 'Method not allowed' }); 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 }, auth: { persistSession: false, autoRefreshToken: false },
}); });
// Fetch task + project + company before deletion using explicit joins const { data: taskRecord } = await admin.from('tasks').select('id').eq('id', taskId).single();
const { data: taskRecord } = await admin.from('tasks').select('id, title, project_id').eq('id', taskId).single();
if (!taskRecord) return res.status(404).json({ error: 'Task not found' }); 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 { data: subs } = await admin.from('submissions').select('id').eq('task_id', taskId);
const subIds = (subs || []).map(s => s.id); 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); const { error } = await admin.from('tasks').delete().eq('id', taskId);
if (error) return res.status(500).json({ error: error.message }); if (error) return res.status(500).json({ error: error.message });
// Archive FileBrowser task folder return res.status(200).json({ ok: true });
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 });
} }
+274 -486
View File
@@ -1,6 +1,10 @@
import { createClient } from '@supabase/supabase-js'; import { createClient } from '@supabase/supabase-js';
const FB_SOURCE = 'srv'; 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) { function json(res, status, body) {
res.status(status).setHeader('Content-Type', 'application/json'); res.status(status).setHeader('Content-Type', 'application/json');
@@ -24,17 +28,6 @@ function joinPath(...parts) {
return normalizePath(parts.join('/')); 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 = '') { function safeName(value, fallback = '') {
const cleaned = String(value || '') const cleaned = String(value || '')
.trim() .trim()
@@ -44,146 +37,67 @@ function safeName(value, fallback = '') {
return cleaned || fallback; return cleaned || fallback;
} }
function parseBody(body) {
if (!body) return {};
if (typeof body === 'string') {
try {
return JSON.parse(body);
} catch {
return {};
}
}
return body;
}
async function readBody(req) {
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() { function getConfig() {
const url = String(process.env.FILEBROWSER_URL || '').trim().replace(/\/+$/, ''); const url = String(process.env.FILEBROWSER_URL || '').trim().replace(/\/+$/, '');
return { return {
url, url,
token: process.env.FILEBROWSER_TOKEN || '', 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'), clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients'),
externalSubsRoot: normalizePath(process.env.FILEBROWSER_SUBS_ROOT || '/fourgebranding/Subcontractors'), subsRoot: normalizePath(process.env.FILEBROWSER_SUBS_ROOT || '/fourgebranding/team'),
externalClientsRoot: normalizePath(process.env.FILEBROWSER_CLIENTS_ROOT || '/fourgebranding/Clients'),
configured: Boolean(url), configured: Boolean(url),
}; };
} }
let runtimeToken = ''; function getToken(config) {
let runtimeTokenTs = 0; const token = String(config.token || '').trim();
const RUNTIME_TOKEN_TTL_MS = 30 * 60 * 1000; if (!token) throw new Error('FILEBROWSER_TOKEN not configured');
return token;
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;
} }
async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) { async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) {
const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString(); const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString();
const url = `${config.url}${endpoint}?${qs}`; const url = `${config.url}${endpoint}?${qs}`;
const token = getToken(config);
async function callWith(token) { const response = await fetch(url, {
return fetch(url, {
method, method,
headers: { Authorization: `Bearer ${token}`, ...headers }, headers: { Authorization: `Bearer ${token}`, ...headers },
body, 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); const text = await response.text();
let res = await callWith(token); try {
return text ? JSON.parse(text) : null;
if (res.status === 401) { } catch {
// Always force-refresh and retry once on Unauthorized. return text;
token = await getToken(config, true);
res = await callWith(token);
} }
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) { async function createCallerClient(authHeader) {
@@ -203,17 +117,16 @@ async function requirePortalUser(authHeader) {
const { data: profile, error: profileError } = await callerClient const { data: profile, error: profileError } = await callerClient
.from('profiles') .from('profiles')
.select('id, name, role, company:companies(id, name)') .select('id, role, company:companies(id, name)')
.eq('id', userData.user.id) .eq('id', userData.user.id)
.single(); .single();
if (profileError) return { ok: false, status: 500, message: profileError.message }; 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' }; return { ok: false, status: 403, message: 'Forbidden' };
} }
// Mirror AuthContext: load all companies (FK + company_members) const clientCompanies = [];
let clientCompanies = [];
if (profile.role === 'client') { if (profile.role === 'client') {
const seen = new Set(); const seen = new Set();
if (profile.company?.id) { if (profile.company?.id) {
@@ -224,400 +137,275 @@ async function requirePortalUser(authHeader) {
.from('company_members') .from('company_members')
.select('company:companies(id, name)') .select('company:companies(id, name)')
.eq('profile_id', userData.user.id); .eq('profile_id', userData.user.id);
for (const m of memberships || []) { for (const row of memberships || []) {
if (m.company?.id && !seen.has(m.company.id)) { if (row.company?.id && !seen.has(row.company.id)) {
clientCompanies.push(m.company); seen.add(row.company.id);
seen.add(m.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 { return {
configured: true, ok: true,
path: vPath, profile: { ...profile, clientCompanies },
canGoUp: vPath !== '/',
parentPath: parentDir(vPath),
entries,
readOnly,
}; };
} }
export const config = { api: { bodyParser: false, sizeLimit: '50mb' } }; function createAdminClient() {
const supabaseUrl = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL;
async function readBody(req) { const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
return new Promise((resolve, reject) => { if (!supabaseUrl || !serviceKey) throw new Error('Supabase admin env not configured');
const chunks = []; return createClient(supabaseUrl, serviceKey, {
req.on('data', chunk => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); auth: { persistSession: false, autoRefreshToken: false },
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
}); });
} }
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) { 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 { try {
const rawBody = await readBody(req); const rawBody = await readBody(req);
const body = (() => { const body = (() => {
if (!rawBody.length) return {}; if (!rawBody.length) return {};
const ct = (req.headers['content-type'] || '').split(';')[0].trim(); const contentType = String(req.headers['content-type'] || '').split(';')[0].trim();
if (ct === 'application/json') { try { return JSON.parse(rawBody.toString()); } catch { return {}; } } if (contentType === 'application/json') {
try {
return JSON.parse(rawBody.toString());
} catch {
return {};
}
}
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); const auth = await requirePortalUser(authHeader);
if (!auth.ok) return json(res, auth.status, { error: auth.message }); if (!auth.ok) return json(res, auth.status, { error: auth.message });
const config = getConfig(); const admin = createAdminClient();
if (!config.configured || !config.token) { if (action === 'ensure-company-folder') {
return json(res, 200, { if (auth.profile.role !== 'team') {
configured: false, return json(res, 403, { error: 'Forbidden' });
error: 'FileBrowser not configured.', }
requiredEnv: ['FILEBROWSER_URL', 'FILEBROWSER_TOKEN'], 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 });
} }
const action = req.query.action || (req.method === 'GET' ? 'list' : ''); if (action === 'rename-company-folder') {
const requestedPath = req.query.path || body?.path || '/'; 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.' });
let externalProjects = []; const { data: company, error: companyError } = await admin
if (auth.profile.role === 'external') { .from('companies')
externalProjects = await getExternalProjects(auth.callerClient, auth.profile.id); .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 });
} }
function toFbPath(vPath = requestedPath) { if (action === 'ensure-profile-folder') {
if (auth.profile.role === 'external') { if (auth.profile.role !== 'team') {
return resolveExternalPath(config, normalizePath(vPath), auth.profile, externalProjects); 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') { if (auth.profile.role === 'client') {
return resolveClientPath(config, normalizePath(vPath), auth.profile.clientCompanies); const allowedCompanyIds = new Set((auth.profile.clientCompanies || []).map(company => company.id));
} if (!allowedCompanyIds.has(project.company_id)) return json(res, 403, { error: 'Forbidden' });
const root = resolveUserRoot(config, auth.profile);
return { virtual: false, fbPath: joinPath(root, normalizePath(vPath)) };
} }
if (req.method === 'GET' && action === 'config') { const companyName = safeName(project.company?.name, String(project.company_id || 'company'));
return json(res, 200, { configured: true, role: auth.profile.role, url: config.url }); 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 (req.method === 'GET' && action === 'list') { if (action === 'ensure-project-folder') {
const vPath = normalizePath(requestedPath); const fullPath = joinPath(config.clientRoot, companyName, 'Projects', projectName);
if (auth.profile.role === 'external') { await ensureDirectory(config, fullPath);
const resolved = resolveExternalPath(config, vPath, auth.profile, externalProjects); for (const folderName of PROJECT_DEFAULT_SUBFOLDERS) {
if (resolved.virtual) { await ensureDirectory(config, joinPath(fullPath, folderName));
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, { ok: true, configured: true, path: fullPath });
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 taskTitle = safeName(req.query?.taskTitle || body.taskTitle, '');
const resolved = toFbPath(); if (!taskTitle) return json(res, 400, { error: 'taskTitle is required.' });
if (resolved.virtual) return json(res, 400, { error: 'Cannot download virtual directory' }); const fullPath = joinPath(config.clientRoot, companyName, 'Projects', projectName, taskTitle);
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') { if (action === 'upload-request-file') {
const resolved = toFbPath(); const bucket = String(body.bucket || 'submissions').trim();
if (resolved.virtual) return json(res, 400, { error: 'Cannot download virtual directory' }); const storagePath = String(body.storagePath || '').trim();
const token = await getToken(config); const fileName = safeName(body.fileName || req.query?.fileName || req.headers['x-file-name'], '');
const downloadUrl = `${config.url}/api/resources/download?source=${FB_SOURCE}&file=${encodeURIComponent(resolved.fbPath)}&auth=${encodeURIComponent(token)}`; if (!storagePath) return json(res, 400, { error: 'storagePath is required.' });
let upstream = await fetch(downloadUrl); if (!fileName) return json(res, 400, { error: 'fileName is required.' });
if (upstream.status === 401) { const surveyPath = joinPath(fullPath, 'Survey');
const fresh = await getToken(config, true); await ensureDirectory(config, surveyPath);
const retryUrl = `${config.url}/api/resources/download?source=${FB_SOURCE}&file=${encodeURIComponent(resolved.fbPath)}&auth=${encodeURIComponent(fresh)}`; const { data: downloadedFile, error: downloadError } = await admin.storage.from(bucket).download(storagePath);
upstream = await fetch(retryUrl); if (downloadError || !downloadedFile) {
return json(res, 500, { error: downloadError?.message || 'Failed to download request file from storage.' });
} }
if (!upstream.ok) { const fileBuffer = Buffer.from(await downloadedFile.arrayBuffer());
const text = await upstream.text(); const uploadUrl = `${config.url}/api/resources?source=${FB_SOURCE}&path=${encodeURIComponent(joinPath(surveyPath, fileName))}&override=true`;
return json(res, upstream.status, { error: text || `Download failed (${upstream.status})` }); const contentType = String(downloadedFile.type || '').trim() || 'application/octet-stream';
} const response = await fetch(uploadUrl, {
method: 'POST',
res.status(upstream.status); headers: {
res.setHeader('Cache-Control', 'no-store'); Authorization: `Bearer ${getToken(config)}`,
res.setHeader('Content-Type', upstream.headers.get('content-type') || 'application/octet-stream'); 'Content-Type': contentType,
res.setHeader('Content-Disposition', upstream.headers.get('content-disposition') || `attachment; filename="${basename(resolved.fbPath) || 'download'}"`); },
const arrayBuffer = await upstream.arrayBuffer(); body: fileBuffer,
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 (!response.ok) {
const text = await response.text().catch(() => '');
return json(res, response.status, { error: text || `Upload failed (${response.status})` });
}
return json(res, 200, { ok: true, configured: true, path: joinPath(surveyPath, fileName) });
} }
if (req.method === 'DELETE' && action === 'delete') { await ensureDirectory(config, fullPath);
const resolved = toFbPath(); for (const folderName of REQUEST_DEFAULT_SUBFOLDERS) {
if (resolved.virtual) return json(res, 400, { error: 'Cannot delete virtual directory' }); await ensureDirectory(config, joinPath(fullPath, folderName));
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 });
} }
return json(res, 200, { ok: true, configured: true, path: fullPath });
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' });
} catch (error) { } 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.' });
} }
} }
-144
View File
@@ -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 });
}
-111
View File
@@ -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 });
}
-117
View File
@@ -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 });
}
-124
View File
@@ -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 });
}
-106
View File
@@ -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 });
}
@@ -0,0 +1,686 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Checked Sites Revision Audit</title>
<style>
:root {
--bg: #f5f1e8;
--panel: rgba(255,255,255,0.78);
--panel-strong: rgba(255,255,255,0.92);
--border: rgba(43,37,28,0.12);
--text: #231d14;
--muted: #6b6255;
--accent: #b8831f;
--accent-soft: rgba(184,131,31,0.18);
--ok: #1f8a4c;
--ok-soft: rgba(31,138,76,0.12);
--warn: #b45309;
--warn-soft: rgba(180,83,9,0.14);
--review: #2563eb;
--review-soft: rgba(37,99,235,0.13);
--todo: #7c3aed;
--todo-soft: rgba(124,58,237,0.12);
--shadow: 0 18px 40px rgba(35,29,20,0.08);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: var(--text);
background:
radial-gradient(circle at top left, rgba(184,131,31,0.14), transparent 28%),
radial-gradient(circle at top right, rgba(37,99,235,0.09), transparent 24%),
linear-gradient(180deg, #fbf8f2 0%, var(--bg) 100%);
}
.wrap {
width: min(1400px, calc(100vw - 40px));
margin: 32px auto 48px;
}
.hero, .panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 18px;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
box-shadow: var(--shadow);
}
.hero {
padding: 28px 30px;
margin-bottom: 24px;
}
.eyebrow {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--muted);
margin-bottom: 10px;
}
h1 {
margin: 0 0 10px;
font-size: 34px;
line-height: 1.05;
letter-spacing: -0.04em;
}
.subtitle {
margin: 0;
color: var(--muted);
font-size: 15px;
max-width: 900px;
}
.grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.card {
padding: 20px 22px;
}
.label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--muted);
margin-bottom: 8px;
}
.value {
font-size: 34px;
line-height: 1;
letter-spacing: -0.05em;
}
.sub {
margin-top: 8px;
font-size: 13px;
color: var(--muted);
}
.two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 24px;
}
.panel {
padding: 20px 22px;
}
h2 {
margin: 0 0 14px;
font-size: 18px;
letter-spacing: -0.03em;
}
.bar-row {
display: grid;
grid-template-columns: 150px 1fr 36px;
gap: 10px;
align-items: center;
margin-bottom: 10px;
}
.bar-label, .bar-value {
font-size: 13px;
}
.bar-track {
height: 10px;
border-radius: 999px;
background: rgba(35,29,20,0.08);
overflow: hidden;
}
.bar-fill {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, var(--accent), #d7a84b);
}
.table-wrap {
overflow: auto;
}
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
th, td {
padding: 10px 12px;
border-bottom: 1px solid rgba(35,29,20,0.08);
vertical-align: top;
text-align: left;
font-size: 13px;
}
th {
position: sticky;
top: 0;
background: var(--panel-strong);
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 11px;
z-index: 1;
}
.mono {
font-variant-numeric: tabular-nums;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 72px;
padding: 4px 10px;
border-radius: 999px;
font-size: 11px;
border: 1px solid transparent;
white-space: nowrap;
}
.ok { color: var(--ok); background: var(--ok-soft); border-color: rgba(31,138,76,0.2); }
.warn { color: var(--warn); background: var(--warn-soft); border-color: rgba(180,83,9,0.2); }
.review { color: var(--review); background: var(--review-soft); border-color: rgba(37,99,235,0.2); }
.todo { color: var(--todo); background: var(--todo-soft); border-color: rgba(124,58,237,0.18); }
.neutral { color: var(--muted); background: rgba(35,29,20,0.06); border-color: rgba(35,29,20,0.08); }
.section {
margin-bottom: 24px;
}
.small {
color: var(--muted);
font-size: 13px;
}
@media (max-width: 1100px) {
.grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.two-col { grid-template-columns: 1fr; }
}
@media (max-width: 700px) {
.wrap { width: min(100vw - 20px, 1400px); margin: 20px auto 32px; }
.hero, .panel { padding: 18px; border-radius: 14px; }
.grid { grid-template-columns: 1fr; }
h1 { font-size: 28px; }
}
</style>
</head>
<body>
<div class="wrap">
<section class="hero">
<div class="eyebrow">Fourge Portal Audit</div>
<h1>Checked Sites Revision Audit</h1>
<p class="subtitle">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.</p>
</section>
<section class="grid">
<div class="panel card">
<div class="label">Checked Sites</div>
<div class="value">26</div>
<div class="sub">Sites found in the database from your checked list.</div>
</div>
<div class="panel card">
<div class="label">Active Revisions</div>
<div class="value">16</div>
<div class="sub">Checked sites that now have an active R01+.</div>
</div>
<div class="panel card">
<div class="label">R00 Billed</div>
<div class="value">23</div>
<div class="sub">Checked sites where the new-book unit is already on an invoice.</div>
</div>
<div class="panel card">
<div class="label">R00 Unbilled</div>
<div class="value">3</div>
<div class="sub">Checked sites completed at R00 but not yet billed.</div>
</div>
</section>
<section class="two-col">
<div class="panel">
<h2>Active Revision Status</h2>
<div class="bar-row">
<div class="bar-label">not_started</div>
<div class="bar-track"><div class="bar-fill" style="width:100%"></div></div>
<div class="bar-value">12</div>
</div>
<div class="bar-row">
<div class="bar-label">client_review</div>
<div class="bar-track"><div class="bar-fill" style="width:25%"></div></div>
<div class="bar-value">3</div>
</div>
<div class="bar-row">
<div class="bar-label">client_approved</div>
<div class="bar-track"><div class="bar-fill" style="width:8%"></div></div>
<div class="bar-value">1</div>
</div>
</div>
<div class="panel">
<h2>Active Revision Depth</h2>
<div class="bar-row">
<div class="bar-label">R01</div>
<div class="bar-track"><div class="bar-fill" style="width:100%"></div></div>
<div class="bar-value">10</div>
</div>
<div class="bar-row">
<div class="bar-label">R02</div>
<div class="bar-track"><div class="bar-fill" style="width:60%"></div></div>
<div class="bar-value">6</div>
</div>
</div>
</section>
<section class="panel section">
<h2>Sites With Active Revisions</h2>
<p class="small">These are the ones where R00 may be done, but a newer version is still active or awaiting review/approval.</p>
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="width:90px;">Site #</th>
<th style="width:240px;">DB Title</th>
<th style="width:80px;">Internal #</th>
<th style="width:96px;">R00 Done</th>
<th style="width:180px;">R00 Billed</th>
<th style="width:90px;">Active R#</th>
<th style="width:120px;">Active Status</th>
<th style="width:170px;">Completed Revisions</th>
<th style="width:220px;">Billed Versions</th>
<th>Version Summary</th>
</tr>
</thead>
<tbody>
<tr>
<td class="mono">00286</td>
<td>00286 Riverside, CA</td>
<td class="mono">31</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill ok">INV-2026-006 (sent)</span></td>
<td><span class="pill review">Yes</span></td>
<td class="mono">R01</td>
<td><span class="pill todo">not_started</span></td>
<td></td>
<td>R00: INV-2026-006 (sent)</td>
<td>R00=completed/billed; R01=not_started</td>
</tr>
<tr>
<td class="mono">30055</td>
<td>30055 Moreno Valley, CA</td>
<td class="mono">33</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill ok">INV-2026-006 (sent)</span></td>
<td><span class="pill review">Yes</span></td>
<td class="mono">R01</td>
<td><span class="pill review">client_review</span></td>
<td></td>
<td>R00: INV-2026-006 (sent)</td>
<td>R00=completed/billed; R01=client_review</td>
</tr>
<tr>
<td class="mono">30096</td>
<td>30096 Eerie, CO</td>
<td class="mono">36</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill ok">INV-2026-006 (sent)</span></td>
<td><span class="pill review">Yes</span></td>
<td class="mono">R02</td>
<td><span class="pill todo">not_started</span></td>
<td>R01 (unbilled)</td>
<td>R00: INV-2026-006 (sent)</td>
<td>R00=completed/billed; R01=completed; R02=not_started</td>
</tr>
<tr>
<td class="mono">68639</td>
<td>68639 NSA Los Lunas NM</td>
<td class="mono">40</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill ok">INV-2026-006 (sent)</span></td>
<td><span class="pill review">Yes</span></td>
<td class="mono">R01</td>
<td><span class="pill review">client_review</span></td>
<td></td>
<td>R00: INV-2026-006 (sent)</td>
<td>R00=completed/billed; R01=client_review</td>
</tr>
<tr>
<td class="mono">68663</td>
<td>68663 NSA Oklahoma City OK</td>
<td class="mono">41</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill ok">INV-2026-006 (sent)</span></td>
<td><span class="pill review">Yes</span></td>
<td class="mono">R01</td>
<td><span class="pill todo">not_started</span></td>
<td></td>
<td>R00: INV-2026-006 (sent)</td>
<td>R00=completed/billed; R01=not_started</td>
</tr>
<tr>
<td class="mono">68664</td>
<td>68664 NSA Oklahoma City OK</td>
<td class="mono">42</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill ok">INV-2026-006 (sent)</span></td>
<td><span class="pill review">Yes</span></td>
<td class="mono">R01</td>
<td><span class="pill todo">not_started</span></td>
<td></td>
<td>R00: INV-2026-006 (sent)</td>
<td>R00=completed/billed; R01=not_started</td>
</tr>
<tr>
<td class="mono">68666</td>
<td>68666 NSA Oklahoma City, OK</td>
<td class="mono">43</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill ok">INV-2026-006 (sent)</span></td>
<td><span class="pill review">Yes</span></td>
<td class="mono">R01</td>
<td><span class="pill todo">not_started</span></td>
<td></td>
<td>R00: INV-2026-006 (sent)</td>
<td>R00=completed/billed; R01=not_started</td>
</tr>
<tr>
<td class="mono">68669</td>
<td>68669 NSA Oklahoma City OK</td>
<td class="mono">44</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill ok">INV-2026-006 (sent)</span></td>
<td><span class="pill review">Yes</span></td>
<td class="mono">R01</td>
<td><span class="pill todo">not_started</span></td>
<td></td>
<td>R00: INV-2026-006 (sent)</td>
<td>R00=completed/billed; R01=not_started</td>
</tr>
<tr>
<td class="mono">68670</td>
<td>68670 NSA Oklahoma City OK</td>
<td class="mono">45</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill ok">INV-2026-006 (sent)</span></td>
<td><span class="pill review">Yes</span></td>
<td class="mono">R02</td>
<td><span class="pill todo">not_started</span></td>
<td>R01 (billed)</td>
<td>R00: INV-2026-006 (sent) | R01: INV-2026-006 (sent)</td>
<td>R00=completed/billed; R01=completed/billed; R02=not_started</td>
</tr>
<tr>
<td class="mono">68671</td>
<td>68671 NSA Oklahoma City OK</td>
<td class="mono">46</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill ok">INV-2026-006 (sent)</span></td>
<td><span class="pill review">Yes</span></td>
<td class="mono">R01</td>
<td><span class="pill todo">not_started</span></td>
<td></td>
<td>R00: INV-2026-006 (sent)</td>
<td>R00=completed/billed; R01=not_started</td>
</tr>
<tr>
<td class="mono">68673</td>
<td>68673 NSA Oklahoma City OK</td>
<td class="mono">47</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill ok">INV-2026-006 (sent)</span></td>
<td><span class="pill review">Yes</span></td>
<td class="mono">R01</td>
<td><span class="pill review">client_review</span></td>
<td></td>
<td>R00: INV-2026-006 (sent)</td>
<td>R00=completed/billed; R01=client_review</td>
</tr>
<tr>
<td class="mono">68721</td>
<td>68721 NSA Mechanicsburg PA</td>
<td class="mono">48</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill ok">INV-2026-006 (sent)</span></td>
<td><span class="pill review">Yes</span></td>
<td class="mono">R02</td>
<td><span class="pill approved">client_approved</span></td>
<td>R01 (billed), R02 (unbilled)</td>
<td>R00: INV-2026-006 (sent) | R01: INV-2026-006 (sent)</td>
<td>R00=completed/billed; R01=completed/billed; R02=completed</td>
</tr>
<tr>
<td class="mono">68808</td>
<td>68808 Camas, WA</td>
<td class="mono">49</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill ok">INV-2026-006 (sent)</span></td>
<td><span class="pill review">Yes</span></td>
<td class="mono">R01</td>
<td><span class="pill todo">not_started</span></td>
<td></td>
<td>R00: INV-2026-006 (sent)</td>
<td>R00=completed/billed; R01=not_started</td>
</tr>
<tr>
<td class="mono">68809</td>
<td>68809 Centralia, WA</td>
<td class="mono">50</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill ok">INV-2026-006 (sent)</span></td>
<td><span class="pill review">Yes</span></td>
<td class="mono">R02</td>
<td><span class="pill todo">not_started</span></td>
<td>R01 (billed)</td>
<td>R00: INV-2026-006 (sent) | R01: INV-2026-006 (sent)</td>
<td>R00=completed/billed; R01=completed/billed; R02=not_started</td>
</tr>
<tr>
<td class="mono">68810</td>
<td>68810 Chehalis, WA</td>
<td class="mono">51</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill ok">INV-2026-006 (sent)</span></td>
<td><span class="pill review">Yes</span></td>
<td class="mono">R02</td>
<td><span class="pill todo">not_started</span></td>
<td>R01 (billed)</td>
<td>R00: INV-2026-006 (sent) | R01: INV-2026-006 (sent)</td>
<td>R00=completed/billed; R01=completed/billed; R02=not_started</td>
</tr>
<tr>
<td class="mono">68811</td>
<td>68811 Kelso, WA</td>
<td class="mono">52</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill ok">INV-2026-006 (sent)</span></td>
<td><span class="pill review">Yes</span></td>
<td class="mono">R02</td>
<td><span class="pill todo">not_started</span></td>
<td>R01 (unbilled)</td>
<td>R00: INV-2026-006 (sent)</td>
<td>R00=completed/billed; R01=completed; R02=not_started</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="panel section">
<h2>Checked Sites With No Active Revision</h2>
<p class="small">These are simpler to read: R00 completed, and no current revision chain is active right now.</p>
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="width:90px;">Site #</th>
<th style="width:240px;">DB Title</th>
<th style="width:80px;">Internal #</th>
<th style="width:96px;">R00 Done</th>
<th style="width:180px;">R00 Billed</th>
<th style="width:90px;">Active R#</th>
<th style="width:120px;">Active Status</th>
<th style="width:170px;">Completed Revisions</th>
<th style="width:220px;">Billed Versions</th>
<th>Version Summary</th>
</tr>
</thead>
<tbody>
<tr>
<td class="mono">30062</td>
<td>30062 Riverside, CA</td>
<td class="mono">34</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill ok">INV-2026-006 (sent)</span></td>
<td><span class="pill neutral">No</span></td>
<td class="mono"></td>
<td><span class="pill neutral"></span></td>
<td></td>
<td>R00: INV-2026-006 (sent)</td>
<td>R00=completed/billed</td>
</tr>
<tr>
<td class="mono">30094</td>
<td>30094 Colorado Springs, CO</td>
<td class="mono">35</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill warn">No</span></td>
<td><span class="pill neutral">No</span></td>
<td class="mono"></td>
<td><span class="pill neutral"></span></td>
<td></td>
<td></td>
<td>R00=completed</td>
</tr>
<tr>
<td class="mono">30258</td>
<td>30258 Lebanon, NH</td>
<td class="mono">38</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill ok">INV-2026-006 (sent)</span></td>
<td><span class="pill neutral">No</span></td>
<td class="mono"></td>
<td><span class="pill neutral"></span></td>
<td></td>
<td>R00: INV-2026-006 (sent)</td>
<td>R00=completed/billed</td>
</tr>
<tr>
<td class="mono">30259</td>
<td>30259 Hamburg, NJ</td>
<td class="mono">39</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill ok">INV-2026-006 (sent)</span></td>
<td><span class="pill neutral">No</span></td>
<td class="mono"></td>
<td><span class="pill neutral"></span></td>
<td></td>
<td>R00: INV-2026-006 (sent)</td>
<td>R00=completed/billed</td>
</tr>
<tr>
<td class="mono">30261</td>
<td>30261 NSA Clovis NM</td>
<td class="mono">70</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill warn">No</span></td>
<td><span class="pill neutral">No</span></td>
<td class="mono"></td>
<td><span class="pill neutral"></span></td>
<td></td>
<td></td>
<td>R00=completed</td>
</tr>
<tr>
<td class="mono">68720</td>
<td>68720 NSA Lancaster PA</td>
<td class="mono">74</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill warn">No</span></td>
<td><span class="pill neutral">No</span></td>
<td class="mono"></td>
<td><span class="pill neutral"></span></td>
<td></td>
<td></td>
<td>R00=completed</td>
</tr>
<tr>
<td class="mono">68724</td>
<td>68724 NSA York PA</td>
<td class="mono">75</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill ok">INV-2026-006 (sent)</span></td>
<td><span class="pill neutral">No</span></td>
<td class="mono"></td>
<td><span class="pill neutral"></span></td>
<td></td>
<td>R00: INV-2026-006 (sent)</td>
<td>R00=completed/billed</td>
</tr>
<tr>
<td class="mono">68752</td>
<td>68752 NSA Brownsville TX</td>
<td class="mono">76</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill ok">INV-2026-006 (sent)</span></td>
<td><span class="pill neutral">No</span></td>
<td class="mono"></td>
<td><span class="pill neutral"></span></td>
<td></td>
<td>R00: INV-2026-006 (sent)</td>
<td>R00=completed/billed</td>
</tr>
<tr>
<td class="mono">68753</td>
<td>68753 NSA Brownsville TX</td>
<td class="mono">77</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill ok">INV-2026-006 (sent)</span></td>
<td><span class="pill neutral">No</span></td>
<td class="mono"></td>
<td><span class="pill neutral"></span></td>
<td></td>
<td>R00: INV-2026-006 (sent)</td>
<td>R00=completed/billed</td>
</tr>
<tr>
<td class="mono">68758</td>
<td>68758 NSA Brownsville TX</td>
<td class="mono">81</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill ok">INV-2026-006 (sent)</span></td>
<td><span class="pill neutral">No</span></td>
<td class="mono"></td>
<td><span class="pill neutral"></span></td>
<td></td>
<td>R00: INV-2026-006 (sent)</td>
<td>R00=completed/billed</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
</body>
</html>
@@ -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"
}
]
@@ -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 |
@@ -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<+0u0K5Ru<Y^j)TE65PQfn?X::/'c.)l-to$RfLSt:U20P]hs;:rfqKnF)^CH.(Mg@hS=(DC4O<@l$jD6tWVJQE!:16Sh0TKn_;HP7&IB;E^sQQQIlgLW8YQ>UWJF-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(_<VF25#;j_*fEYsgp]W?EcPZF@"ME,u7cfTqI<'fYp[sKpVb$c?nntf0)M(cF6,r'gB3)a9K`TiEP(J3QYMXR_=S"bQc],Ku=%4c;eB1sDI-VcAL-%:l*;&[Cj#T0I=![_?m9FIa-9`r:JC`8J(M>dPo)]AE*e<?`#AhV1On["(dIV:JPa,-9oIhHP?&4Z0d)^opEbOLTT2Ak$Qe0hu]M3df'djG%uIYlb89Nu)%_ktSkNt-V&3e[+b_9mPorKJ3n2G-[:-o%RG`oIIAI)G&6Ytm0PRl(i3XT(8n7H`ue)<Gh.iEaOe\P8,3lS&@Z`Oo@iaT')kJ*o_Y#+e)U-r'1X77f$F6<F17*.kd3B:!:RaqBdEh8V0]7?hbM3$'Nf+GgpoiFdd]kbA`BQ)H*n_F=omQu:Vn)!+5OP;q>XYEdb4?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`<rX2EWRKQMnJ?I.:YkUj#*V%C[TE="Q&Gp:&#Yt;';gWnLc0#a0c"Tt,4tuQ[*7Lo<`_-n=DWQcr7b!2eJ0mT4[%NXbE$g]6=2P:\W;)P@rg8Uh=!ciJj!1/qa7Gba%flUGZ`"7C8;;`ljcM\*:%ji]e/L=f&T>4]!6$W@?s1@%c7,?S)4NGB9:q\7AUOHg`bFpj"nuQ*(<c":O@fencCYR<.Z;,X3D^[8'$T%n.tm-gn-=!c8-p+-$3@UMNk@A$G9l0Y?2?-;mi;iMHRJn,T=Fca>@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%F1VHiQ7o<Pt*IUU>ICKn']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\MDgNk<pD227"jUChsCcLPu_(E'gCIP""p]Bo7_(<6]jmVQM<dd[L6m:1gBZ*OWZ1r84ds2C744Ka=3@0D2]e)5-"[[Ie3KWQ+M(tGANU\t8@+8Xb:^T3p90W0mDJtg(BS:FT7'lcQJCH=F`l$ZaP$<5EWXH7Vm&[]QNXH6%6b%HFb+.RJY6/^k5YLToZ<IA$(J0s9@YsI$pknDXa##4T'h>SNJ"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=<aJ>V=%3Q`"/hV'lH%X>;Gc^k3?][I4IZrb)\[l:6lMF)\fNZs1b9lmVi:+g"%ij1IaRg&3i:Y($)6GhNNJt/TRkQd$$G<F*@7@'?^)0i!mn8&+g<[e."TOVPgK)"6&&s(oQd?)+]g>NpIG3KoX5B!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;u7AKEZ</5p9dWOd-od)=R$j]\>WS4kgS^)CV%+pWunO97D=Jh'sl"8O\6LcHFn*]^/i[LZgn3cThZO+G+!$4>!5T'to!>U<=_+XQ;d/1.N</QoD5Z%hZ.ZM45-P8'0gmblH#go=7c<1UKme7&SnI?PZ[GVbfj7])_n!-jQ#:[%3J_eg/V\6WRAHe%.?ip9c_p:3EHSG3rKDT9E>?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<NfO=B+H\T"[+#n?B7"pba31!gCfB$UCtBMgFVU5]P>'$[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<Br@L;Mgfm92]Q:7tFLK=[-Q]oO1p/e^u@(Ig[YE<j!@hK,`)c.A0j7K4g_X#/<<_uRhh!.mCPpDK1(p_^nqC"-&33`cg(X+Co;'ES5Glk2L(_=qG-:9a2/V:h;f.U#..B`i,9I(lad7,"K;B][D6#!<k@/-tA.ldO=L[4ce/$uo@.Z$W8r9_L.E]Gg9";7Ua%C7&2\!jn*]\u]I49q>'1d@B5NL/d9L,3+-*&73orSQpkeUqF18,?^V!Stc&(:AHU9(XN1D,W>eBr&Y4Jk<p";kCaMGJFJ_#DB%q-H\'uEQDjD?;DB8pQeLU=I6uZ"6[YK0k#pg)o3c+@Mk,WYu4,-^ekD3mf>P97t?'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<X_U>^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;"fZj<Z(;b\_>djr-/gV($&(8;_hF@.2&YIh6saqZE8hD=RJ%Im_O85sQT'?2oq97rpq,n6lSH%d&gcKhdcr-e%iaD09!@+pPs+/>A\K27cr/UCBi`kk"=`@V/C:l$9T(<?m3(FU(<rYh*G&EDo1VpAg-I&"#r:BJ19%'p)"L_[G*".s(sL-M]B+3['XR=f]*^<?"uF?e<?lQt/K:+i$8T_u"P]WB`Sd,,dpKP].bcb!hMV;1`(5hgLntUoBgjPn>_GY,<Ku/2O;Or>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<SrEO)r5V40T-h[*;L6HH-/B9=LdHS?3AP"p%:#WQN73VU0:^N^K##FEgAd=$"`,\A3kqkD='U4JFfV,CU+Q',bUUF,`3,S(cl!gG&Q)=fFca$Dk]kHQ)eqBL4o;f(D,X\l(.nd4S1-?1[b5%FA]-Q48Wg/G)AkO9PB@g3dU\%cs.L7QOgKs3V59X\ao-.LG=3.$b):P=65cE=:Y?JJR,RQ]Be:U\Y-hR-qGm)/`g42TXE86$i@:_<j>]?NZj\R\p7RMie2j4_Hru+90D?l<KO!CR0eIqoEotqD`)#GE:=&H`H*'F^69hhc&_"UnO-70($@Et@'d3aQ)$>U'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.=)<p`?ZXLHAMKYDXRIs#8L8>/.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^$<?DeT1pQo`=-[*#QgRR]emHrF_[k2g8p5\RJZ."9lbNRVb]$nt!%Wpl7hBTV\]&/kBRaKPho/=0q7S@sMiT0>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\<dI3G`@*,=9Y[3t?b-f;e7$gLRhEQJ[sVe3h4`ZXD%b:LksFbeH?P\/V";8-[FotS&r"p8T%bMkd)'(r4`"d8/_cNrCd1PT\$O&5G8H#6[NV6lCSP)>DrZ6L]_U*L2'`X$%X>0DB6WT83oN09h=UWpoL%;,l?lC7a,V#(Z3BX#,=];X"56>[2U0((UL!D<7=0F6D;/>b4)*Vp5^O5,]8Lj\$/YdNZKEH_58<L\cM,&`8W<AOk7Tf:F#lVV%VR6_g,@j6/bWcLp=[AkmE%cE\EU:HZhr&pP0We3AA:nN-'J,]&aV1@hrd:N=":Ks$`I]YV"fJF(bj-7&6otM\NN0G_HHCEMDCg+jKXtrAW?@@nDLbK&qdJVIT/4m^T=(X$V+3"/=.ZMY4DBdcDSj5rrRd-hl!OQn</@&,TKfq;CkHp?044MARi$ie[ioBA=4rZc2/\dBtr#?47cFbbIFg+!V1q5Z<f"+.loL_Xd%MK/.8G'R>b.12k$XFb,iQ*%qQna08h\<b48NF9]:5P_@*!9Qg$b&?^@UUAT,IF%/pL(+CR2~>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
[<dccedbca14e5931c52cd86c3e6456193><dccedbca14e5931c52cd86c3e6456193>]
% ReportLab generated PDF document -- digest (opensource)
/Info 9 0 R
/Root 8 0 R
/Size 14
>>
startxref
8981
%%EOF
+339
View File
@@ -0,0 +1,339 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Fourge Portal Finance Summary</title>
<style>
:root {
--bg: #ffffff;
--text: #161616;
--muted: #5e5e5e;
--accent: #f5a523;
--border: #e5dccf;
--surface: #faf7f2;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: var(--text);
background: var(--bg);
line-height: 1.45;
}
.page {
width: 100%;
max-width: 920px;
margin: 0 auto;
padding: 44px 40px 56px;
}
.hero {
border: 1px solid var(--border);
background: linear-gradient(135deg, #fffaf2 0%, #ffffff 55%, #fff5e2 100%);
border-radius: 16px;
padding: 28px 30px;
margin-bottom: 28px;
}
h1 {
margin: 0 0 8px;
font-size: 30px;
line-height: 1.1;
font-weight: 700;
letter-spacing: -0.03em;
}
.sub {
margin: 0;
color: var(--muted);
font-size: 14px;
}
h2 {
margin: 28px 0 10px;
font-size: 19px;
line-height: 1.15;
}
h3 {
margin: 0 0 8px;
font-size: 15px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #7d6d58;
}
p { margin: 0 0 12px; }
ul {
margin: 0;
padding-left: 18px;
}
li { margin: 0 0 6px; }
.grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
margin-top: 14px;
}
.card {
border: 1px solid var(--border);
border-radius: 14px;
background: var(--surface);
padding: 16px 16px 14px;
}
.card p:last-child,
.flow-box p:last-child { margin-bottom: 0; }
.flow {
display: grid;
gap: 10px;
margin-top: 12px;
}
.flow-box {
border: 1px solid var(--border);
border-radius: 12px;
padding: 14px 16px;
background: #fff;
position: relative;
}
.flow-box + .flow-box::before {
content: "↓";
position: absolute;
top: -18px;
left: 50%;
transform: translateX(-50%);
color: var(--accent);
font-weight: 700;
}
.pill {
display: inline-block;
padding: 4px 8px;
border-radius: 999px;
background: #fff4dd;
border: 1px solid #f3d194;
font-size: 12px;
margin: 0 6px 6px 0;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
font-size: 13px;
}
th, td {
text-align: left;
padding: 10px 8px;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
th {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
font-weight: 700;
}
.code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
background: #fff;
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px 14px;
white-space: pre-wrap;
}
.footer {
margin-top: 28px;
color: var(--muted);
font-size: 12px;
}
@media print {
.page { padding: 24px 24px 36px; }
.hero, .card, .flow-box { break-inside: avoid; }
h2, h3 { break-after: avoid; }
table { break-inside: avoid; }
}
</style>
</head>
<body>
<main class="page">
<section class="hero">
<h1>Fourge Portal Finance Summary</h1>
<p class="sub">Role cheat sheet, finance lanes, and data flow for team, client, and subcontractor users.</p>
</section>
<section>
<h2>1. Executive Summary</h2>
<p>The finance area is not one shared page for all roles. It is split into three lanes with different permissions and different page sets.</p>
<div class="grid">
<article class="card">
<h3>Team</h3>
<p>Full finance hub at <strong>/invoices</strong>.</p>
<p>Handles client invoices, expenses, subcontractor invoices, and subcontractor purchase orders.</p>
</article>
<article class="card">
<h3>Client</h3>
<p>Invoice-only lane at <strong>/my-invoices</strong>.</p>
<p>Clients can view invoices, filter by company, and download PDFs.</p>
</article>
<article class="card">
<h3>Subcontractor</h3>
<p>Two finance lanes: <strong>/my-invoices-sub</strong> and <strong>/my-purchase-orders</strong>.</p>
<p>They can submit invoices to Fourge and approve POs sent by Fourge.</p>
</article>
</div>
</section>
<section>
<h2>2. Role-by-Role Cheat Sheet</h2>
<table>
<thead>
<tr>
<th>Role</th>
<th>Main Routes</th>
<th>Can Do</th>
<th>Cannot Do</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Team</strong></td>
<td>/invoices</td>
<td>Create/send client invoices, track expenses, create POs, review subcontractor invoices, mark paid, export/send PDFs</td>
<td>Not limited in finance UI</td>
</tr>
<tr>
<td><strong>Client</strong></td>
<td>/my-invoices</td>
<td>View invoices, filter by company, download invoice PDFs</td>
<td>No expenses, no invoice editing, no POs, no subcontractor finance tools</td>
</tr>
<tr>
<td><strong>Subcontractor / External</strong></td>
<td>/my-invoices-sub<br>/my-purchase-orders</td>
<td>Create/submit invoices to Fourge, view status, download receipts after payment, approve POs</td>
<td>No team finance overview, no client invoice management, no internal expenses</td>
</tr>
</tbody>
</table>
</section>
<section>
<h2>3. Core Data Models</h2>
<div class="card">
<div class="pill">invoices</div>
<div class="pill">invoice_items</div>
<div class="pill">expenses</div>
<div class="pill">subcontractor_invoices</div>
<div class="pill">subcontractor_invoice_items</div>
<div class="pill">subcontractor_payments</div>
<div class="pill">subcontractor_po_items</div>
<div class="pill">tasks</div>
<div class="pill">submissions</div>
<div class="pill">companies</div>
<div class="pill">profiles</div>
<p style="margin-top:10px;">Simple mental model: <strong>invoices</strong> bill clients, <strong>expenses</strong> track internal costs, <strong>subcontractor_invoices</strong> let subcontractors bill Fourge, and <strong>subcontractor_payments</strong> are POs Fourge sends to subcontractors.</p>
</div>
</section>
<section>
<h2>4. System Flow</h2>
<div class="flow">
<div class="flow-box">
<h3>Client Billing Flow</h3>
<p>Approved client work reaches <strong>tasks.status = client_approved</strong>.</p>
<p>Team finance loads uninvoiced tasks and revisions, builds line items, saves an invoice, and emails the PDF to the client.</p>
<p>Later, team marks the invoice paid.</p>
</div>
<div class="flow-box">
<h3>Expense Flow</h3>
<p>Team logs expenses, optionally with receipt files.</p>
<p>Expenses feed overview charts, yearly summaries, and profit calculations.</p>
</div>
<div class="flow-box">
<h3>Subcontractor Invoice Flow</h3>
<p>Subcontractor creates an invoice from completed approved tasks assigned to them.</p>
<p>Fourge reviews it, then marks it paid and sends a receipt PDF back.</p>
</div>
<div class="flow-box">
<h3>Purchase Order Flow</h3>
<p>Team creates a PO for a subcontractor, optionally tied to project tasks.</p>
<p>Subcontractor reviews the PO and can approve it from their own portal view.</p>
</div>
</div>
</section>
<section>
<h2>5. Status Logic</h2>
<div class="grid">
<article class="card">
<h3>Client Invoice</h3>
<p><strong>draft</strong><strong>sent</strong><strong>paid</strong></p>
</article>
<article class="card">
<h3>Subcontractor Invoice</h3>
<p><strong>draft</strong><strong>submitted</strong><strong>paid</strong></p>
</article>
<article class="card">
<h3>Purchase Order</h3>
<p><strong>draft</strong><strong>sent</strong><strong>approved</strong><strong>ready_to_pay</strong><strong>paid</strong></p>
<p><strong>cancelled</strong> is exit state.</p>
</article>
</div>
</section>
<section>
<h2>6. Key Business Rules</h2>
<ul>
<li>Client invoices only pull approved, uninvoiced work.</li>
<li>Tasks and revisions are marked invoiced after invoice creation.</li>
<li>Deleting a team invoice rolls linked invoice flags back.</li>
<li>Team invoice status changes also push task status changes.</li>
<li>Revision billing rule: <strong>R00 = new work</strong>, <strong>R01 = free</strong>, <strong>R02+ = billable increments</strong>.</li>
<li>Subcontractor invoices are created by the external user and then reviewed/paid by team.</li>
<li>POs are created by team and approved by the subcontractor.</li>
</ul>
</section>
<section>
<h2>7. Database Relationship Chart</h2>
<div class="code">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</div>
</section>
<section>
<h2>8. Quick Route Map</h2>
<table>
<thead>
<tr>
<th>Surface</th>
<th>Route</th>
<th>User</th>
</tr>
</thead>
<tbody>
<tr><td>Finance Hub</td><td>/invoices</td><td>Team</td></tr>
<tr><td>Client Invoices</td><td>/my-invoices</td><td>Client</td></tr>
<tr><td>Subcontractor Invoices</td><td>/my-invoices-sub</td><td>External</td></tr>
<tr><td>Create Subcontractor Invoice</td><td>/my-invoices-sub/new</td><td>External</td></tr>
<tr><td>Purchase Orders</td><td>/my-purchase-orders</td><td>External</td></tr>
</tbody>
</table>
</section>
<p class="footer">Generated from current portal codebase summary on June 3, 2026.</p>
</main>
</body>
</html>
Binary file not shown.
+379
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
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 `
<div class="bar-row">
<div class="bar-label">${esc(label)}</div>
<div class="bar-track"><div class="bar-fill" style="width:${pct}%"></div></div>
<div class="bar-value">${count}</div>
</div>
`;
})
.join('');
}
function renderTableRows(items) {
return items
.map((row) => {
const billedClass = row.r00Billed === 'No' ? 'warn' : 'ok';
const activeClass = badgeClassForStatus(row.activeStatus);
return `
<tr>
<td class="mono">${esc(row.site)}</td>
<td>${esc(row.dbTitle)}</td>
<td class="mono">${esc(row.internalTaskNumber)}</td>
<td><span class="pill ${row.r00Completed === 'Yes' ? 'ok' : 'neutral'}">${esc(row.r00Completed)}</span></td>
<td><span class="pill ${billedClass}">${esc(row.r00Billed)}</span></td>
<td><span class="pill ${row.hasActiveRevision === 'Yes' ? 'review' : 'neutral'}">${esc(row.hasActiveRevision)}</span></td>
<td class="mono">${esc(row.activeRevision)}</td>
<td><span class="pill ${activeClass}">${esc(row.activeStatus)}</span></td>
<td>${esc(row.completedRevisions)}</td>
<td>${esc(row.billedVersions)}</td>
<td>${esc(row.versionSummary)}</td>
</tr>
`;
})
.join('');
}
const html = `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Checked Sites Revision Audit</title>
<style>
:root {
--bg: #f5f1e8;
--panel: rgba(255,255,255,0.78);
--panel-strong: rgba(255,255,255,0.92);
--border: rgba(43,37,28,0.12);
--text: #231d14;
--muted: #6b6255;
--accent: #b8831f;
--accent-soft: rgba(184,131,31,0.18);
--ok: #1f8a4c;
--ok-soft: rgba(31,138,76,0.12);
--warn: #b45309;
--warn-soft: rgba(180,83,9,0.14);
--review: #2563eb;
--review-soft: rgba(37,99,235,0.13);
--todo: #7c3aed;
--todo-soft: rgba(124,58,237,0.12);
--shadow: 0 18px 40px rgba(35,29,20,0.08);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: var(--text);
background:
radial-gradient(circle at top left, rgba(184,131,31,0.14), transparent 28%),
radial-gradient(circle at top right, rgba(37,99,235,0.09), transparent 24%),
linear-gradient(180deg, #fbf8f2 0%, var(--bg) 100%);
}
.wrap {
width: min(1400px, calc(100vw - 40px));
margin: 32px auto 48px;
}
.hero, .panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 18px;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
box-shadow: var(--shadow);
}
.hero {
padding: 28px 30px;
margin-bottom: 24px;
}
.eyebrow {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--muted);
margin-bottom: 10px;
}
h1 {
margin: 0 0 10px;
font-size: 34px;
line-height: 1.05;
letter-spacing: -0.04em;
}
.subtitle {
margin: 0;
color: var(--muted);
font-size: 15px;
max-width: 900px;
}
.grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.card {
padding: 20px 22px;
}
.label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--muted);
margin-bottom: 8px;
}
.value {
font-size: 34px;
line-height: 1;
letter-spacing: -0.05em;
}
.sub {
margin-top: 8px;
font-size: 13px;
color: var(--muted);
}
.two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 24px;
}
.panel {
padding: 20px 22px;
}
h2 {
margin: 0 0 14px;
font-size: 18px;
letter-spacing: -0.03em;
}
.bar-row {
display: grid;
grid-template-columns: 150px 1fr 36px;
gap: 10px;
align-items: center;
margin-bottom: 10px;
}
.bar-label, .bar-value {
font-size: 13px;
}
.bar-track {
height: 10px;
border-radius: 999px;
background: rgba(35,29,20,0.08);
overflow: hidden;
}
.bar-fill {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, var(--accent), #d7a84b);
}
.table-wrap {
overflow: auto;
}
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
th, td {
padding: 10px 12px;
border-bottom: 1px solid rgba(35,29,20,0.08);
vertical-align: top;
text-align: left;
font-size: 13px;
}
th {
position: sticky;
top: 0;
background: var(--panel-strong);
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 11px;
z-index: 1;
}
.mono {
font-variant-numeric: tabular-nums;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 72px;
padding: 4px 10px;
border-radius: 999px;
font-size: 11px;
border: 1px solid transparent;
white-space: nowrap;
}
.ok { color: var(--ok); background: var(--ok-soft); border-color: rgba(31,138,76,0.2); }
.warn { color: var(--warn); background: var(--warn-soft); border-color: rgba(180,83,9,0.2); }
.review { color: var(--review); background: var(--review-soft); border-color: rgba(37,99,235,0.2); }
.todo { color: var(--todo); background: var(--todo-soft); border-color: rgba(124,58,237,0.18); }
.neutral { color: var(--muted); background: rgba(35,29,20,0.06); border-color: rgba(35,29,20,0.08); }
.section {
margin-bottom: 24px;
}
.small {
color: var(--muted);
font-size: 13px;
}
@media (max-width: 1100px) {
.grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.two-col { grid-template-columns: 1fr; }
}
@media (max-width: 700px) {
.wrap { width: min(100vw - 20px, 1400px); margin: 20px auto 32px; }
.hero, .panel { padding: 18px; border-radius: 14px; }
.grid { grid-template-columns: 1fr; }
h1 { font-size: 28px; }
}
</style>
</head>
<body>
<div class="wrap">
<section class="hero">
<div class="eyebrow">Fourge Portal Audit</div>
<h1>Checked Sites Revision Audit</h1>
<p class="subtitle">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.</p>
</section>
<section class="grid">
<div class="panel card">
<div class="label">Checked Sites</div>
<div class="value">${existingRows.length}</div>
<div class="sub">Sites found in the database from your checked list.</div>
</div>
<div class="panel card">
<div class="label">Active Revisions</div>
<div class="value">${activeRows.length}</div>
<div class="sub">Checked sites that now have an active R01+.</div>
</div>
<div class="panel card">
<div class="label">R00 Billed</div>
<div class="value">${r00BilledRows.length}</div>
<div class="sub">Checked sites where the new-book unit is already on an invoice.</div>
</div>
<div class="panel card">
<div class="label">R00 Unbilled</div>
<div class="value">${r00UnbilledRows.length}</div>
<div class="sub">Checked sites completed at R00 but not yet billed.</div>
</div>
</section>
<section class="two-col">
<div class="panel">
<h2>Active Revision Status</h2>
${barChart(Object.entries(activeStatusCounts), maxStatusCount)}
</div>
<div class="panel">
<h2>Active Revision Depth</h2>
${barChart(Object.entries(revisionDepthCounts), maxDepthCount)}
</div>
</section>
<section class="panel section">
<h2>Sites With Active Revisions</h2>
<p class="small">These are the ones where R00 may be done, but a newer version is still active or awaiting review/approval.</p>
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="width:90px;">Site #</th>
<th style="width:240px;">DB Title</th>
<th style="width:80px;">Internal #</th>
<th style="width:96px;">R00 Done</th>
<th style="width:180px;">R00 Billed</th>
<th style="width:90px;">Active R#</th>
<th style="width:120px;">Active Status</th>
<th style="width:170px;">Completed Revisions</th>
<th style="width:220px;">Billed Versions</th>
<th>Version Summary</th>
</tr>
</thead>
<tbody>
${renderTableRows(activeRows)}
</tbody>
</table>
</div>
</section>
<section class="panel section">
<h2>Checked Sites With No Active Revision</h2>
<p class="small">These are simpler to read: R00 completed, and no current revision chain is active right now.</p>
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="width:90px;">Site #</th>
<th style="width:240px;">DB Title</th>
<th style="width:80px;">Internal #</th>
<th style="width:96px;">R00 Done</th>
<th style="width:180px;">R00 Billed</th>
<th style="width:90px;">Active R#</th>
<th style="width:120px;">Active Status</th>
<th style="width:170px;">Completed Revisions</th>
<th style="width:220px;">Billed Versions</th>
<th>Version Summary</th>
</tr>
</thead>
<tbody>
${renderTableRows(noActiveRows)}
</tbody>
</table>
</div>
</section>
</div>
</body>
</html>`;
fs.writeFileSync(outputPath, html);
console.log(fileURLToPath(outputPath));
+362
View File
@@ -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'<para alignment="center" backColor="{bg}" borderColor="{fg}" '
f'borderWidth="0.7" borderPadding="4" textColor="{fg}"><b>{escape(text)}</b></para>'
),
styles["pill"],
)
def escape(value):
return (
str(value or "")
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
)
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()
+301
View File
@@ -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);
File diff suppressed because it is too large Load Diff
+55
View File
@@ -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)<br>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)<br>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)<br>R01: INV-2026-006 (sent) | — |
| 68810 | 68810 Chehalis, WA | R02 | not_started | R00: INV-2026-006 (sent)<br>R01: INV-2026-006 (sent) | — |
| 68811 | 68811 Kelso, WA | R02 | not_started | R00: INV-2026-006 (sent) | R01 |
## Unchecked But Already Billed
- None
File diff suppressed because it is too large Load Diff
+185
View File
@@ -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 |
+2
View File
@@ -9,6 +9,8 @@
document.documentElement.style.colorScheme = theme === 'light' ? 'light' : 'dark'; document.documentElement.style.colorScheme = theme === 'light' ? 'light' : 'dark';
})(); })();
</script> </script>
<link rel="preconnect" href="https://fqflxxqvennhvoeywrdw.supabase.co" crossorigin />
<link rel="dns-prefetch" href="https://fqflxxqvennhvoeywrdw.supabase.co" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" /> <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png" /> <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png" />
+446 -61
View File
@@ -15,6 +15,15 @@ This is the single source of truth for dashboard/profile visual structure and UI
- radial glow + vertical dark gradient. - radial glow + vertical dark gradient.
- Light base token `--bg` is full gradient: - Light base token `--bg` is full gradient:
- gray radial glow + white vertical 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. - Do not use `html` theme-gradient scripting for Safari chrome behavior.
## 3) Tokens ## 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** - **`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 - 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 - 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` - 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 - 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 - icon-only filter/action buttons share the same row and align vertically with add buttons
- filter button wrapper: `<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>``display: flex` prevents block stretching that misaligns the button vertically - filter button wrapper: `<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>``display: flex` prevents block stretching that misaligns the button vertically
- filter button: `className="btn btn-outline"` + `style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}`, icon only (no label), funnel SVG `<path d="M2 4h12M4 8h8M6 12h4" />` at `13×13`; dropdown uses `site-header-avatar-menu` + `site-header-avatar-item` classes - filter button: `className="btn btn-outline"` + `style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}`, icon only (no label), funnel SVG `<path d="M2 4h12M4 8h8M6 12h4" />` at `13×13`; dropdown uses `site-header-avatar-menu` + `site-header-avatar-item` classes
- when the control bar uses a multi-column grid (to align with split-card layouts below), add `align-items: center` to the grid and `min-height: var(--btn-height)` to every column so row height is stable across all tab states - 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) ## 7) Dashboard Grids (Team)
- Stat row: `grid-template-columns: 1fr 1fr 1fr 1.5fr`, `gap: 24`, `margin-bottom: 0` - 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` - Meta/date text: `11px`
- Progress track: `height: 4px`, `radius: 2px` - Progress track: `height: 4px`, `radius: 2px`
- Percentage width slot: `min-width: 28px` - 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`) - 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 - use shared class treatment (`.card-empty-center`) for consistency
- avoid top-offset-only placement for empty text - 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` - row vertical spacing via cell padding: typically `5px`
- Hot Tasks column widths: - Hot Tasks column widths:
- check `10%`, task `40%`, requested by `35%`, due by `15%` - 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: - Sorting rule:
- Every visible data column header must be sortable. - Every visible data column header must be sortable.
- Use clickable header controls (`SortTh`) with ascending/descending indicator. - 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: - Activity feed includes:
- actions performed by the viewed user - 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) - `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` ## 12.25) Shared Portrait Logic
- Left stacked cards (`Pinned`, `Navigation`): `gap: 24` - Shared profile/avatar rendering must use one common resolver path instead of page-specific inline `<img>` fallbacks.
- Canonical folder structure: - Canonical shared component: `ProfileAvatar`
- team and subcontractor profile folders live at `Team/<User Name>` - Portrait source resolution order:
- company folders live at `Clients/<Company Name>` - explicit `avatarUrl` prop when already resolved
- project folders live at `Clients/<Company Name>/Projects/<Project Name>` - `profile.avatar_url`
- every project auto-creates `00 Project Files` - profile lookup by `profileId`
- task folders live at `Clients/<Company Name>/Projects/<Project Name>/<Task Name>` - profile lookup by normalized display `name` as a fallback when only sender/actor name exists in derived dashboard data
- every task auto-creates `Request Info`, `Working Files`, and `Old Books` - initials fallback when no profile image is available
- request submission files are copied into `Request Info/R00`, `R01`, and higher revision folders - Shared portrait behavior:
- renaming a team user, subcontractor user, company, project, or task must rename the matching filesystem folder - image uses `object-fit: cover`
- folder repair/backfill is additive only: create missing folders, leave existing folders untouched - shape is always circular (`border-radius: 50%`)
- request-info backfill must skip files that already exist and must not overwrite added working files or user-managed files - fallback initials use accent background with dark text
- deleting a task in the portal moves its task folder into `Archive/Clients/<Company Name>/Projects/<Project Name>/<Task Name>` instead of deleting it - layout/header/task/project/dashboard surfaces should reuse the shared portrait resolver instead of hand-rolling image + initials logic
- deleting a project in the portal moves its project folder into `Archive/Clients/<Company Name>/Projects/<Project Name>` instead of deleting it
- orphan reconciliation may move folders into archive when they no longer map to portal records ## 12.3) Team Dashboard Data Logic
- orphan team folders move to `Archive/Team/<Folder Name>` when no team or subcontractor profile matches that folder name - Team dashboard is shared across roles, but card data is role-filtered by scoped tasks/projects/companies.
- orphan project folders move to `Archive/Clients/<Company Name>/Projects/<Project Name>` when no portal project matches that folder name under that company - Focus refresh + realtime refresh should be wired through the shared live-refresh path, not duplicated per page.
- orphan reconciliation does not remove company roots or overwrite archived content; it merge-moves into archive - Scoped company/project/task IDs should be resolved through the shared scope resolver for client/external views.
- Access/home rules: - Tall dashboard list cards should use viewport-driven height with internal scroll instead of hard-coded row counts:
- team members have full file access - measure the lower dashboard row against the current window height and size the cards to the remaining viewport space
- client users with exactly one tied company use that company folder as home: `/Clients/<Company Name>` - do not use a generic fixed tall-card height that can force whole-page scrolling on shorter windows
- client users tied to multiple companies use `/Clients` as home and only see company folders they are tied to - do not apply a lower clamp that stops the card from continuing to compress with the window
- `/Clients` acts as a virtual parent for multi-company client users and must not expose unrelated company folders - scrolling stays inside the card body, not on the full page layout
- subcontractor users use virtual home `/` - `Team Performance` card logic:
- subcontractor virtual home shows `Team` plus only the project folders they are assigned to - source data is `deliveries` joined to `submissions` and `tasks`
- subcontractor users skip all other root folders and must not see unrelated client or internal roots - each delivery row must include `submission.task_id` plus nested task assignee info
- subcontractor `Team` maps to the real shared `Team` folder - non-team roles must filter deliveries back down to their scoped task IDs before calculating performance rows
- each subcontractor project folder is a virtual root that maps to the real client project path `/Clients/<Company>/Projects/<Project>` - month dropdown options are derived only from months that actually contain scoped delivery data
- Right card header: - initial render must use the first valid month with data; card must not render empty until the dropdown is manually changed
- title label is `File Browser` - performer identity resolves first from `task.assigned_to`, then falls back to normalized `sent_by` / display name matching against profiles
- drag/drop note sits directly under the title and uses subtext sizing: `12px` - avatar in `Team Performance` follows shared portrait resolution rules above
- current file path uses the same `11px` uppercase label treatment as the title row family - counts:
- toolbar does not show `+ Folder` or `Upload` buttons - `version_number === 0` => `new`
- upload happens by dragging and dropping files or folders into the right card - `version_number > 0` => `revision`
- 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 - progress bar percentage is performer completed count divided by total completed count in the active month
- Right card table: - Dashboard lower-left task card logic:
- uses dashboard table styling (`table-layout: fixed`, transparent header row, `10px` uppercase headers, `13px/12px` body text) - team view remains `Hot Tasks`
- card padding matches dashboard widget shell (`18px 21px`) - client view remains `Tasks Ready For Review`
- clickable text inside the table highlights in accent; rows do not use block hover fill - external/subcontractor view is `My Tasks`
- body cells are borderless like dashboard card tables - subcontractor `My Tasks` must source directly from scoped tasks assigned to the current subcontractor, not from `is_hot` submission flags
- body cell padding matches dashboard tables exactly: `5px` - subcontractor `My Tasks` should include only active assigned tasks:
- visible headers (`Name`, `Size`, `Modified`) use shared sortable header controls - `not_started`
- file/folder icon footprint is compact so row height stays on the same cadence as dashboard tables - `in_progress`
- alternating rows use a very subtle theme-aware tint for scanability - `client_review`
- Navigation tree: - `on_hold`
- `Home` aligns to the first arrow column - subcontractor `My Tasks` should exclude completed/closed states such as:
- connector line runs through the arrow center column - `client_approved`
- pinned folders are user-specific and must not be shared across users on the same device/browser session - `invoiced`
- hover is text-only accent color, no row fill - `paid`
- subcontractor `My Tasks` rows should show task, project, status, and due date
## 13) Radius + Geometry Rules ## 13) Radius + Geometry Rules
- Dashboard/profile widgets: `8px` radius - 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 - 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 - 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 - 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 - Inputs/dropdowns may use their own control radius unless a page-specific rule says otherwise
- Circular elements (avatar/day/icon badges): `50%` - 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 ## 14.5) Loading Popup
- Use shared `PageLoader` component for loading overlays instead of page-specific popup implementations. - 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)` - `background: var(--popup-bg)`
- `border: 1px solid var(--border)` - `border: 1px solid var(--border)`
- `border-radius: 8px` - `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)` - 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 - 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 - 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 ## 14.7) Status Tags
- Shared status tags use `StatusBadge` (`.badge.badge-status`). - 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` - fast `160ms`
- base `220ms` - base `220ms`
- easing `cubic-bezier(0.22, 1, 0.36, 1)` - 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 ## 17) Hover Interaction Contract
- Sidebar, header icon buttons, dropdown items, and avatar menu items must show a visible hover surface before click. - 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 and active must be visually distinct:
- hover uses stronger temporary contrast (`bg` + thin border), - hover uses stronger temporary contrast (`bg` + thin border),
- active remains persistent selected-state background. - 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 ## 17.5) Link Interaction Standard
- Use shared link classes only; do not hand-roll page-specific link hover styles: - Use shared link classes only; do not hand-roll page-specific link hover styles:
- table/text links inside cards and tables: `table-link` - 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`. - 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 ## 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 - Keep widget shell values (`18px 21px`, `8px`, blur 12, border token) consistent
- Maintain global `24px` spacing rhythm for page/frame/grid gaps - Maintain global `24px` spacing rhythm for page/frame/grid gaps
- Keep team dashboard card order: - 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 3. Net Profit
4. Revenue (wide) 4. Revenue (wide)
- Keep row 2 order: Recent Activity (left), Tasks In Progress (center), Calendar (right fixed at `280px`) - 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 ## 18) Invoice Rules
- New work (`R00`) is billed at the new-service rate. - New work (`R00`) is billed at the new-service rate.
- Revision `R01` is free. - Revision `R01` is free.
- Revisions `R02+` are billed incrementally: each uninvoiced revision entry contributes exactly `1x` revision charge. - 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). - 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
-83
View File
@@ -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); });
-127
View File
@@ -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); });
-212
View File
@@ -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); });
@@ -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); });
+180
View File
@@ -0,0 +1,180 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Task Tracker PDF Generator</title>
<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2/dist/umd/supabase.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script>
<style>
body { font-family: -apple-system, sans-serif; background: #111; color: #fff; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; flex-direction: column; gap: 16px; }
button { background: #F5A523; color: #000; border: none; border-radius: 8px; padding: 12px 28px; font-size: 14px; font-weight: 600; cursor: pointer; }
button:disabled { opacity: 0.5; cursor: default; }
#status { font-size: 13px; color: #888; }
select { background: #222; color: #fff; border: 1px solid #333; border-radius: 8px; padding: 8px 12px; font-size: 13px; min-width: 220px; }
</style>
</head>
<body>
<div style="font-size:22px;font-weight:700;letter-spacing:-0.5px">Task Tracker PDF</div>
<select id="companyFilter"><option value="">All Companies</option></select>
<button id="btn" onclick="generate()">Generate &amp; Download PDF</button>
<div id="status">Ready</div>
<script>
const SUPABASE_URL = 'https://fqflxxqvennhvoeywrdw.supabase.co';
const SUPABASE_ANON_KEY = 'sb_publishable_qNNIKtnu1dUIVKelq9aYYQ_TfHgzhyR';
const { createClient } = supabase;
const sb = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
const STATUS_LABELS = {
not_started: 'Not Started',
in_progress: 'In Progress',
on_hold: 'On Hold',
client_review: 'In Review',
client_approved: 'Approved',
invoiced: 'Invoiced',
paid: 'Paid',
};
const versionLabel = (v) => `R${String(v ?? 0).padStart(2, '0')}`;
const isInvoiced = (t) => t.invoiced || t.status === 'invoiced' || t.status === 'paid';
// Populate company dropdown on load
(async () => {
const { data } = await sb
.from('tasks')
.select('project:projects(company:companies(id, name))')
.limit(1000);
const seen = new Map();
for (const t of data || []) {
const c = t.project?.company;
if (c && !seen.has(c.id)) seen.set(c.id, c);
}
const sel = document.getElementById('companyFilter');
[...seen.values()]
.sort((a, b) => a.name.localeCompare(b.name))
.forEach(c => {
const opt = document.createElement('option');
opt.value = c.id;
opt.textContent = c.name;
sel.appendChild(opt);
});
})();
async function generate() {
const btn = document.getElementById('btn');
const status = document.getElementById('status');
const selectedCompany = document.getElementById('companyFilter').value;
const selectedCompanyName = document.getElementById('companyFilter').selectedOptions[0]?.text || 'All Companies';
btn.disabled = true;
status.textContent = 'Fetching data from Supabase…';
try {
const { data, error } = await sb
.from('tasks')
.select('id, title, status, current_version, invoiced, submitted_at, project:projects(id, name, company:companies(id, name))')
.order('submitted_at', { ascending: false });
if (error) throw error;
let rows = data || [];
if (selectedCompany) {
rows = rows.filter(t => t.project?.company?.id === selectedCompany);
}
status.textContent = `Building PDF for ${rows.length} tasks…`;
const { jsPDF } = window.jspdf;
const doc = new jsPDF({ orientation: 'portrait', unit: 'pt', format: 'letter' });
// Header
doc.setFont('helvetica', 'bold');
doc.setFontSize(18);
doc.setTextColor(245, 165, 35);
doc.text('Fourge Branding', 40, 48);
doc.setFont('helvetica', 'normal');
doc.setFontSize(11);
doc.setTextColor(120, 120, 120);
doc.text('Task Tracker', 40, 64);
doc.setFontSize(9);
const now = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
doc.text(`Generated ${now} · ${selectedCompanyName} · ${rows.length} tasks`, 40, 78);
// Table
doc.autoTable({
startY: 96,
head: [['TASK NAME', 'VERSION', 'STATUS', 'INVOICED']],
body: rows.map(t => [
[
t.title || '—',
t.project?.company?.name && t.project?.name
? `${t.project.company.name} · ${t.project.name}`
: (t.project?.name || ''),
],
versionLabel(t.current_version),
STATUS_LABELS[t.status] || t.status || '—',
isInvoiced(t) ? 'Yes' : '—',
]),
styles: {
fontSize: 9,
cellPadding: { top: 5, right: 8, bottom: 5, left: 8 },
overflow: 'linebreak',
textColor: [30, 30, 30],
},
headStyles: {
fillColor: [245, 165, 35],
textColor: [0, 0, 0],
fontStyle: 'bold',
fontSize: 8,
},
alternateRowStyles: {
fillColor: [248, 248, 248],
},
columnStyles: {
0: { cellWidth: 220 },
1: { cellWidth: 55, halign: 'center' },
2: { cellWidth: 90 },
3: { cellWidth: 60, halign: 'center' },
},
willDrawCell(data) {
if (data.column.index === 0 && data.section === 'body') {
const [title, sub] = Array.isArray(data.cell.raw) ? data.cell.raw : [data.cell.raw, ''];
const x = data.cell.x + 8;
let y = data.cell.y + 13;
doc.setFont('helvetica', 'bold');
doc.setFontSize(9);
doc.setTextColor(30, 30, 30);
doc.text(String(title), x, y, { maxWidth: 204 });
if (sub) {
const titleLines = doc.splitTextToSize(String(title), 204).length;
y += titleLines * 11;
doc.setFont('helvetica', 'normal');
doc.setFontSize(8);
doc.setTextColor(140, 140, 140);
doc.text(String(sub), x, y, { maxWidth: 204 });
}
data.cell.text = [];
}
},
margin: { left: 40, right: 40 },
});
const slug = selectedCompany ? selectedCompanyName.replace(/\s+/g, '-').toLowerCase() : 'all';
const filename = `task-tracker-${slug}-${new Date().toISOString().slice(0, 10)}.pdf`;
doc.save(filename);
status.textContent = `Downloaded: ${filename}`;
} catch (err) {
status.textContent = `Error: ${err.message}`;
console.error(err);
} finally {
btn.disabled = false;
}
}
</script>
</body>
</html>
-104
View File
@@ -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); });
+26 -10
View File
@@ -52,8 +52,8 @@ const TeamSubInvoiceDetail = lazy(() => import('./pages/team/TeamSubInvoiceDetai
const SurveyMaker = lazy(() => import('./pages/SurveyMaker')); const SurveyMaker = lazy(() => import('./pages/SurveyMaker'));
const BrandBook = lazy(() => import('./pages/BrandBook')); const BrandBook = lazy(() => import('./pages/BrandBook'));
const Converters = lazy(() => import('./pages/Converters')); const Converters = lazy(() => import('./pages/Converters'));
const FileSharing = lazy(() => import('./pages/FileSharing'));
const TeamFourgePasswords = lazy(() => import('./pages/team/TeamFourgePasswords')); const TeamFourgePasswords = lazy(() => import('./pages/team/TeamFourgePasswords'));
const TeamReports = lazy(() => import('./pages/team/TeamReports'));
const ExternalMyPurchaseOrders = lazy(() => import('./pages/external/ExternalMyPurchaseOrders')); const ExternalMyPurchaseOrders = lazy(() => import('./pages/external/ExternalMyPurchaseOrders'));
const ExternalMyInvoices = lazy(() => import('./pages/external/ExternalMyInvoices')); const ExternalMyInvoices = lazy(() => import('./pages/external/ExternalMyInvoices'));
const ExternalMyInvoiceDetail = lazy(() => import('./pages/external/ExternalMyInvoiceDetail')); const ExternalMyInvoiceDetail = lazy(() => import('./pages/external/ExternalMyInvoiceDetail'));
@@ -73,6 +73,16 @@ function RedirectToTask() {
return <Navigate to={`/tasks/${id}`} replace />; return <Navigate to={`/tasks/${id}`} replace />;
} }
function RedirectTeamInvoiceDetail() {
const { id } = useParams();
return <Navigate to={`/finances/${id}`} replace />;
}
function RedirectSubsInvoiceDetail() {
const { id } = useParams();
return <Navigate to={`/subs-invoices/${id}`} replace />;
}
function NavigateCompanyDetail() { function NavigateCompanyDetail() {
const { id } = useParams(); const { id } = useParams();
return <Navigate to={`/company/${id}`} replace />; return <Navigate to={`/company/${id}`} replace />;
@@ -100,25 +110,30 @@ export default function App() {
<Route path="/tasks" element={<ProtectedRoute role={['team', 'external', 'client']}><RequestsPage /></ProtectedRoute>} /> <Route path="/tasks" element={<ProtectedRoute role={['team', 'external', 'client']}><RequestsPage /></ProtectedRoute>} />
<Route path="/requests" element={<Navigate to="/tasks" replace />} /> <Route path="/requests" element={<Navigate to="/tasks" replace />} />
<Route path="/team-projects" element={<Navigate to="/tasks" replace />} /> <Route path="/team-projects" element={<Navigate to="/tasks" replace />} />
<Route path="/invoices" element={<ProtectedRoute role="team"><TeamInvoices /></ProtectedRoute>} /> <Route path="/finances" element={<ProtectedRoute role="team"><TeamInvoices /></ProtectedRoute>} />
<Route path="/invoices/new" element={<ProtectedRoute role="team"><TeamCreateInvoice /></ProtectedRoute>} /> <Route path="/finances/new" element={<ProtectedRoute role="team"><TeamCreateInvoice /></ProtectedRoute>} />
<Route path="/subcontractor-pos/new" element={<ProtectedRoute role="team"><TeamCreateSubcontractorPO /></ProtectedRoute>} /> <Route path="/subcontractor-pos/new" element={<ProtectedRoute role="team"><TeamCreateSubcontractorPO /></ProtectedRoute>} />
<Route path="/invoices/:id" element={<ProtectedRoute role="team"><TeamInvoiceDetail /></ProtectedRoute>} /> <Route path="/finances/:id" element={<ProtectedRoute role="team"><TeamInvoiceDetail /></ProtectedRoute>} />
<Route path="/subcontractor-pos/:id" element={<ProtectedRoute role="team"><TeamSubcontractorPODetail /></ProtectedRoute>} /> <Route path="/subcontractor-pos/:id" element={<ProtectedRoute role="team"><TeamSubcontractorPODetail /></ProtectedRoute>} />
<Route path="/sub-invoices/:id" element={<ProtectedRoute role="team"><TeamSubInvoiceDetail /></ProtectedRoute>} /> <Route path="/sub-invoices/:id" element={<ProtectedRoute role="team"><TeamSubInvoiceDetail /></ProtectedRoute>} />
<Route path="/invoices" element={<Navigate to="/finances" replace />} />
<Route path="/invoices/new" element={<Navigate to="/finances/new" replace />} />
<Route path="/invoices/:id" element={<RedirectTeamInvoiceDetail />} />
<Route path="/survey-maker" element={<ProtectedRoute role={['team', 'external']}><SurveyMaker /></ProtectedRoute>} /> <Route path="/survey-maker" element={<ProtectedRoute role={['team', 'external']}><SurveyMaker /></ProtectedRoute>} />
<Route path="/brand-book" element={<ProtectedRoute role={['team', 'external']}><BrandBook /></ProtectedRoute>} /> <Route path="/brand-book" element={<ProtectedRoute role={['team', 'external']}><BrandBook /></ProtectedRoute>} />
<Route path="/converters" element={<ProtectedRoute role={['team', 'external']}><Converters /></ProtectedRoute>} /> <Route path="/converters" element={<ProtectedRoute role={['team', 'external']}><Converters /></ProtectedRoute>} />
<Route path="/file-sharing" element={<ProtectedRoute role={['team', 'external', 'client']}><FileSharing /></ProtectedRoute>} />
<Route path="/file-uploads" element={<Navigate to="/file-sharing" replace />} />
<Route path="/fourge-passwords" element={<ProtectedRoute role="team"><TeamFourgePasswords /></ProtectedRoute>} /> <Route path="/fourge-passwords" element={<ProtectedRoute role="team"><TeamFourgePasswords /></ProtectedRoute>} />
<Route path="/reports" element={<ProtectedRoute role="team"><TeamReports /></ProtectedRoute>} />
<Route path="/server-status" element={<Navigate to="/dashboard" replace />} /> <Route path="/server-status" element={<Navigate to="/dashboard" replace />} />
<Route path="/assigned-requests" element={<Navigate to="/tasks" replace />} /> <Route path="/assigned-requests" element={<Navigate to="/tasks" replace />} />
<Route path="/my-purchase-orders" element={<ProtectedRoute role="external"><ExternalMyPurchaseOrders /></ProtectedRoute>} /> <Route path="/my-purchase-orders" element={<ProtectedRoute role="external"><ExternalMyPurchaseOrders /></ProtectedRoute>} />
<Route path="/my-projects-sub" element={<Navigate to="/tasks" replace />} /> <Route path="/my-projects-sub" element={<Navigate to="/tasks" replace />} />
<Route path="/my-invoices-sub" element={<ProtectedRoute role="external"><ExternalMyInvoices /></ProtectedRoute>} /> <Route path="/subs-invoices" element={<ProtectedRoute role="external"><ExternalMyInvoices /></ProtectedRoute>} />
<Route path="/my-invoices-sub/new" element={<ProtectedRoute role="external"><ExternalMyInvoiceCreate /></ProtectedRoute>} /> <Route path="/subs-invoices/new" element={<ProtectedRoute role="external"><ExternalMyInvoiceCreate /></ProtectedRoute>} />
<Route path="/my-invoices-sub/:id" element={<ProtectedRoute role="external"><ExternalMyInvoiceDetail /></ProtectedRoute>} /> <Route path="/subs-invoices/:id" element={<ProtectedRoute role="external"><ExternalMyInvoiceDetail /></ProtectedRoute>} />
<Route path="/my-invoices-sub" element={<Navigate to="/subs-invoices" replace />} />
<Route path="/my-invoices-sub/new" element={<Navigate to="/subs-invoices/new" replace />} />
<Route path="/my-invoices-sub/:id" element={<RedirectSubsInvoiceDetail />} />
<Route path="/profile" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} /> <Route path="/profile" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} />
<Route path="/profile/:id" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} /> <Route path="/profile/:id" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} />
@@ -130,7 +145,8 @@ export default function App() {
<Route path="/my-requests/:id" element={<RedirectToTask />} /> <Route path="/my-requests/:id" element={<RedirectToTask />} />
<Route path="/my-projects" element={<Navigate to="/tasks" replace />} /> <Route path="/my-projects" element={<Navigate to="/tasks" replace />} />
<Route path="/my-projects/:id" element={<RedirectProjectDetail />} /> <Route path="/my-projects/:id" element={<RedirectProjectDetail />} />
<Route path="/my-invoices" element={<ProtectedRoute role="client"><ClientMyInvoices /></ProtectedRoute>} /> <Route path="/client-invoices" element={<ProtectedRoute role="client"><ClientMyInvoices /></ProtectedRoute>} />
<Route path="/my-invoices" element={<Navigate to="/client-invoices" replace />} />
<Route path="/new-request" element={<Navigate to="/tasks" replace />} /> <Route path="/new-request" element={<Navigate to="/tasks" replace />} />
<Route path="/new-project" element={<Navigate to="/tasks" replace />} /> <Route path="/new-project" element={<Navigate to="/tasks" replace />} />
+4 -3
View File
@@ -1,4 +1,4 @@
import { useState, useRef } from 'react'; import { useId, useRef, useState } from 'react';
const MAX_FILES = 20; const MAX_FILES = 20;
const MAX_SIZE_MB = 250; const MAX_SIZE_MB = 250;
@@ -10,6 +10,7 @@ const formatSize = (bytes) => {
}; };
export default function FileAttachment({ files, onChange }) { export default function FileAttachment({ files, onChange }) {
const inputId = useId();
const [errors, setErrors] = useState([]); const [errors, setErrors] = useState([]);
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
const dragCounter = useRef(0); const dragCounter = useRef(0);
@@ -79,8 +80,8 @@ export default function FileAttachment({ files, onChange }) {
transition: 'all 160ms', cursor: 'pointer', transition: 'all 160ms', cursor: 'pointer',
}} }}
> >
<input type="file" multiple onChange={handleChange} style={{ display: 'none' }} id="req-file-upload" /> <input type="file" multiple onChange={handleChange} style={{ display: 'none' }} id={inputId} />
<label htmlFor="req-file-upload" style={{ cursor: 'pointer', textTransform: 'none', letterSpacing: 'normal', fontWeight: 400 }}> <label htmlFor={inputId} style={{ cursor: 'pointer', textTransform: 'none', letterSpacing: 'normal', fontWeight: 400 }}>
<div style={{ fontSize: 20, marginBottom: 4 }}>{dragging ? '📂' : '📎'}</div> <div style={{ fontSize: 20, marginBottom: 4 }}>{dragging ? '📂' : '📎'}</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}> <div style={{ fontSize: 13, color: 'var(--text-primary)' }}>
{dragging {dragging
-520
View File
@@ -1,520 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import LoadingButton from './LoadingButton';
import SortTh from './SortTh';
import { supabase } from '../lib/supabase';
import { popupMenuStyle } from '../lib/popupStyles';
const TABLE_TH = { fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px', border: 'none', background: 'transparent', verticalAlign: 'top' };
const TABLE_TD = { padding: '5px', fontSize: 13, color: 'var(--text-primary)', border: 'none', background: 'transparent' };
const LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' };
const ITEM_STYLE = { display: 'flex', alignItems: 'center', gap: 8, padding: '7px 14px', fontSize: 13, cursor: 'pointer', color: 'var(--text-primary)', background: 'none', border: 'none', width: '100%', textAlign: 'left', borderRadius: 4, whiteSpace: 'nowrap' };
const SEP = { height: 1, background: 'var(--border)', margin: '4px 0' };
function formatBytes(bytes) {
const value = Number(bytes || 0);
if (!value) return '—';
const units = ['B', 'KB', 'MB', 'GB'];
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
return `${(value / (1024 ** index)).toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
}
function formatDate(dt) {
if (!dt) return '—';
const d = new Date(dt);
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + ' ' + d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
}
function fileIconStyle(ext) {
const e = ext.toLowerCase();
if (['jpg','jpeg','png','gif','webp','svg','ico','bmp','tiff','avif','heic'].includes(e)) return { bg: '#16a34a', color: '#fff' };
if (['mp4','mov','avi','mkv','webm','m4v','wmv','flv'].includes(e)) return { bg: '#7c3aed', color: '#fff' };
if (['mp3','wav','ogg','flac','aac','m4a'].includes(e)) return { bg: '#db2777', color: '#fff' };
if (e === 'pdf') return { bg: '#dc2626', color: '#fff' };
if (['doc','docx','rtf'].includes(e)) return { bg: '#2563eb', color: '#fff' };
if (['txt','md'].includes(e)) return { bg: '#64748b', color: '#fff' };
if (['xls','xlsx','csv'].includes(e)) return { bg: '#16a34a', color: '#fff' };
if (['ppt','pptx'].includes(e)) return { bg: '#ea580c', color: '#fff' };
if (['zip','rar','7z','tar','gz'].includes(e)) return { bg: '#92400e', color: '#fff' };
if (['ai','eps'].includes(e)) return { bg: '#ff6c00', color: '#fff' };
if (['psd','psb'].includes(e)) return { bg: '#001e36', color: '#31a8ff' };
if (['indd','idml'].includes(e)) return { bg: '#49021f', color: '#ff3366' };
if (['fig','sketch'].includes(e)) return { bg: '#7c3aed', color: '#fff' };
return { bg: '#475569', color: '#fff' };
}
function FileIcon({ entry }) {
if (entry.type === 'dir') {
return (
<div style={{ width: 24, height: 24, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" />
</svg>
</div>
);
}
const ext = (entry.name.includes('.') ? entry.name.split('.').pop() : '').toUpperCase().slice(0, 4) || 'FILE';
const { bg, color } = fileIconStyle(ext);
return (
<div style={{ width: 24, height: 24, borderRadius: 4, background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<span style={{ color, fontSize: 7, fontWeight: 600, letterSpacing: 0.3, fontFamily: 'monospace' }}>{ext}</span>
</div>
);
}
function pathParts(path) { return String(path || '/').split('/').filter(Boolean); }
function pathTo(index, parts) { return `/${parts.slice(0, index + 1).join('/')}`; }
function joinVirtualPath(...parts) { return ('/' + parts.join('/')).replace(/\/+/g, '/').replace(/\/$/, '') || '/'; }
export default function FileBrowser({ initialPath = '/', rootPath = '/' }) {
const [currentPath, setCurrentPath] = useState(initialPath);
const [entries, setEntries] = useState([]);
const [parentPath, setParentPath] = useState('/');
const [canGoUp, setCanGoUp] = useState(false);
const [loading, setLoading] = useState(true);
const [working, setWorking] = useState('');
const [dragging, setDragging] = useState(false);
const [error, setError] = useState('');
const [readOnly, setReadOnly] = useState(false);
const [folderName, setFolderName] = useState('');
const [showFolderInput, setShowFolderInput] = useState(false);
const [renamingEntry, setRenamingEntry] = useState(null);
const [renameValue, setRenameValue] = useState('');
const [uploadProgress, setUploadProgress] = useState(null);
const [selected, setSelected] = useState(new Set());
const [sortKey, setSortKey] = useState('name');
const [sortDir, setSortDir] = useState('asc');
const [ctxMenu, setCtxMenu] = useState(null);
const [clipboard, setClipboard] = useState(null);
const fileInputRef = useRef(null);
const folderInputRef = useRef(null);
const breadcrumbs = useMemo(() => pathParts(currentPath), [currentPath]);
useEffect(() => {
if (!ctxMenu) return;
const close = () => setCtxMenu(null);
window.addEventListener('click', close);
return () => window.removeEventListener('click', close);
}, [ctxMenu]);
const apiFetch = async (url, options = {}) => {
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) throw new Error('Session expired. Please sign in again.');
const response = await fetch(url, {
...options,
headers: {
Authorization: `Bearer ${session.access_token}`,
...(options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
...(options.headers || {}),
},
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || 'File request failed.');
return data;
};
const loadFiles = async (path = currentPath) => {
setLoading(true); setError(''); setSelected(new Set());
try {
const params = new URLSearchParams({ action: 'list', path });
const data = await apiFetch(`/api/filebrowser?${params}`);
if (data.configured === false) { setError(data.error || 'File browser not configured.'); return; }
setEntries(data.entries || []);
setCurrentPath(data.path || path);
setParentPath(data.parentPath || '/');
setCanGoUp(data.canGoUp || false);
setReadOnly(data.readOnly || false);
} catch (err) { setError(err.message); }
finally { setLoading(false); }
};
useEffect(() => { loadFiles(initialPath); }, [initialPath]); // eslint-disable-line react-hooks/exhaustive-deps
const toggleSort = (col) => {
if (sortKey === col) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
else { setSortKey(col); setSortDir(col === 'modified' ? 'desc' : 'asc'); }
};
const sortedEntries = useMemo(() => {
return [...entries].sort((a, b) => {
const aDir = a.type === 'dir'; const bDir = b.type === 'dir';
if (aDir !== bDir) return aDir ? -1 : 1;
let av, bv;
if (sortKey === 'name') { av = (a.name || '').toLowerCase(); bv = (b.name || '').toLowerCase(); }
else if (sortKey === 'size') { av = Number(a.size || 0); bv = Number(b.size || 0); }
else if (sortKey === 'modified') { av = new Date(a.mtime || 0).getTime(); bv = new Date(b.mtime || 0).getTime(); }
else { av = ''; bv = ''; }
const r = typeof av === 'string' ? av.localeCompare(bv) : (av > bv ? 1 : av < bv ? -1 : 0);
return sortDir === 'asc' ? r : -r;
});
}, [entries, sortKey, sortDir]);
const downloadFile = async (entry) => {
setWorking(`dl:${entry.path}`);
try {
const data = await apiFetch(`/api/filebrowser?action=download&path=${encodeURIComponent(entry.path)}`);
if (data.url && data.token) {
const sep = data.url.includes('?') ? '&' : '?';
const a = document.createElement('a');
a.href = `${data.url}${sep}auth=${encodeURIComponent(data.token)}`;
a.download = entry.name; a.target = '_blank'; a.rel = 'noopener noreferrer';
document.body.appendChild(a); a.click(); document.body.removeChild(a);
}
} catch (err) { setError(err.message); }
finally { setWorking(''); }
};
const downloadSelected = async () => {
if (!selected.size) return;
setWorking('dl-sel');
try {
for (const name of selected) {
const entry = entries.find(e => e.name === name);
if (entry && entry.type !== 'dir') await downloadFile(entry);
}
} finally { setWorking(''); }
};
const deleteEntry = async (entry) => {
if (!window.confirm(`Delete "${entry.name}"? This cannot be undone.`)) return;
setWorking(`del:${entry.path}`);
try {
await apiFetch(`/api/filebrowser?action=delete&path=${encodeURIComponent(entry.path)}`, { method: 'DELETE' });
await loadFiles(currentPath);
} catch (err) { setError(err.message); }
finally { setWorking(''); }
};
const deleteSelected = async () => {
if (!selected.size || !window.confirm(`Delete ${selected.size} item(s)?`)) return;
for (const name of selected) {
const entry = entries.find(e => e.name === name);
if (!entry) continue;
await apiFetch(`/api/filebrowser?action=delete&path=${encodeURIComponent(entry.path)}`, { method: 'DELETE' }).catch(() => {});
}
setSelected(new Set());
await loadFiles(currentPath);
};
const createFolder = async (e) => {
e.preventDefault();
if (!folderName.trim()) return;
setWorking('mkdir');
try {
await apiFetch('/api/filebrowser?action=mkdir', { method: 'POST', body: JSON.stringify({ path: currentPath, name: folderName }) });
setFolderName(''); setShowFolderInput(false);
await loadFiles(currentPath);
} catch (err) { setError(err.message); }
finally { setWorking(''); }
};
const renameEntry = async (e) => {
e.preventDefault();
const newName = renameValue.trim();
if (!newName || newName === renamingEntry.name) { setRenamingEntry(null); return; }
setWorking(`ren:${renamingEntry.path}`);
try {
await apiFetch('/api/filebrowser?action=rename', { method: 'POST', body: JSON.stringify({ path: renamingEntry.path, name: newName }) });
setRenamingEntry(null);
await loadFiles(currentPath);
} catch (err) { setError(err.message); }
finally { setWorking(''); }
};
const pasteClipboard = async () => {
if (!clipboard) return;
setWorking('paste');
try {
for (const item of clipboard.items) {
await apiFetch('/api/filebrowser?action=' + (clipboard.mode === 'copy' ? 'copy' : 'move'), {
method: 'POST',
body: JSON.stringify({ srcPath: item.path, dstParentPath: currentPath }),
});
}
if (clipboard.mode === 'move') setClipboard(null);
await loadFiles(currentPath);
} catch (err) { setError(err.message); }
finally { setWorking(''); }
};
async function uploadOneFile(vPath, file, retries = 3) {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
await apiFetch(`/api/filebrowser?action=upload&path=${encodeURIComponent(vPath)}`, {
method: 'POST', headers: { 'Content-Type': 'application/octet-stream' }, body: file,
});
return;
} catch (err) {
if (attempt === retries) throw err;
await new Promise(r => setTimeout(r, attempt * 1000));
}
}
}
const uploadFiles = async (files) => {
const sel = Array.from(files || []);
if (!sel.length) return;
setWorking('upload'); setUploadProgress(0); setError('');
try {
for (let i = 0; i < sel.length; i++) {
await uploadOneFile(joinVirtualPath(currentPath, sel[i].name), sel[i]);
setUploadProgress(Math.round(((i + 1) / sel.length) * 100));
}
} catch (err) { setError(err.message); }
finally { setWorking(''); setUploadProgress(null); if (fileInputRef.current) fileInputRef.current.value = ''; setDragging(false); await loadFiles(currentPath); }
};
const uploadFolder = async (files) => {
const sel = Array.from(files || []).filter(f => f.webkitRelativePath);
if (!sel.length) return;
setWorking('upload'); setUploadProgress(0); setError('');
try {
const dirsNeeded = new Set();
for (const file of sel) {
const parts = file.webkitRelativePath.split('/').slice(0, -1);
for (let i = 1; i <= parts.length; i++) dirsNeeded.add(parts.slice(0, i).join('/'));
}
for (const dir of [...dirsNeeded].sort((a, b) => a.split('/').length - b.split('/').length)) {
await apiFetch('/api/filebrowser?action=mkdir', { method: 'POST', body: JSON.stringify({ path: currentPath, name: dir.split('/').pop() }) }).catch(() => {});
}
for (let i = 0; i < sel.length; i++) {
await uploadOneFile(joinVirtualPath(currentPath, sel[i].webkitRelativePath), sel[i]);
setUploadProgress(Math.round(((i + 1) / sel.length) * 100));
}
} catch (err) { setError(err.message); }
finally { setWorking(''); setUploadProgress(null); if (folderInputRef.current) folderInputRef.current.value = ''; await loadFiles(currentPath); }
};
const handleDragEnter = (e) => { e.preventDefault(); if (!readOnly) setDragging(true); };
const handleDragOver = (e) => { e.preventDefault(); if (!readOnly) { e.dataTransfer.dropEffect = 'copy'; setDragging(true); } };
const handleDragLeave = (e) => { e.preventDefault(); if (!e.currentTarget.contains(e.relatedTarget)) setDragging(false); };
const handleDrop = async (e) => {
e.preventDefault(); setDragging(false);
if (readOnly) return;
const items = Array.from(e.dataTransfer.items || []);
const fsEntries = items.map(i => i.webkitGetAsEntry?.()).filter(Boolean);
if (fsEntries.some(en => en.isDirectory)) {
const readFsEntry = (entry) => new Promise(resolve => {
if (entry.isFile) { entry.file(file => { Object.defineProperty(file, 'webkitRelativePath', { value: entry.fullPath.replace(/^\//, ''), writable: false, configurable: true }); resolve([file]); }); }
else if (entry.isDirectory) { const reader = entry.createReader(); const readAll = acc => reader.readEntries(async entries => { if (!entries.length) { resolve(acc); return; } const nested = await Promise.all(entries.map(readFsEntry)); readAll([...acc, ...nested.flat()]); }); readAll([]); }
else { resolve([]); }
});
uploadFolder((await Promise.all(fsEntries.map(readFsEntry))).flat());
} else {
if (!e.dataTransfer.files?.length) return;
uploadFiles(e.dataTransfer.files);
}
};
const toggleSelect = (name) => setSelected(prev => { const next = new Set(prev); next.has(name) ? next.delete(name) : next.add(name); return next; });
const openCtxMenu = (e, entry, childPath) => {
e.preventDefault(); e.stopPropagation();
setCtxMenu({ x: e.clientX, y: e.clientY, entry, childPath });
};
return (
<div
style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, borderRadius: 8, border: `1px solid ${dragging ? 'var(--accent)' : 'var(--border)'}`, transition: 'border-color 140ms ease', position: 'relative', overflow: 'hidden' }}
onDragEnter={handleDragEnter} onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop}
>
{dragging && (
<div style={{ position: 'absolute', inset: 0, zIndex: 10, background: 'rgba(245,165,35,0.08)', display: 'flex', alignItems: 'center', justifyContent: 'center', pointerEvents: 'none' }}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 28, marginBottom: 8 }}></div>
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--accent)' }}>Drop files to upload</div>
</div>
</div>
)}
{/* Toolbar */}
<div style={{ padding: '18px 21px', borderBottom: '1px solid var(--border)', flexShrink: 0, position: 'relative' }}>
{/* Selection actions — absolute top-right like FileSharing */}
{selected.size > 0 && (
<div style={{ position: 'absolute', top: 18, right: 21, display: 'flex', gap: 8 }}>
<button className="btn btn-outline" disabled={Boolean(working)} onClick={downloadSelected}>Download ({selected.size})</button>
{!readOnly && <button className="btn btn-danger" disabled={Boolean(working)} onClick={deleteSelected}>Delete ({selected.size})</button>}
</div>
)}
<div style={{ paddingRight: selected.size > 0 ? 260 : 0 }}>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 2 }}>
<div style={LABEL}>Files</div>
{uploadProgress !== null && <span style={{ fontSize: 12, color: 'var(--text-muted)', marginLeft: 12 }}>Uploading {uploadProgress}%</span>}
{!readOnly && (
<>
<input ref={folderInputRef} type="file" style={{ display: 'none' }} onChange={e => uploadFolder(e.target.files)} {...{ webkitdirectory: '' }} />
<input ref={fileInputRef} type="file" multiple style={{ display: 'none' }} onChange={e => uploadFiles(e.target.files)} />
</>
)}
</div>
<div style={{ fontSize: 12, color: dragging ? 'var(--accent)' : 'var(--text-muted)', marginTop: 2, marginBottom: 14, transition: 'color 140ms ease' }}>
Drag and drop files or folders here.
</div>
{/* Breadcrumbs */}
<div style={{ fontSize: 11, fontWeight: 500, letterSpacing: 0.6, textTransform: 'uppercase', color: 'var(--text-secondary)', display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
<button type="button" onClick={() => loadFiles(rootPath)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 11, fontWeight: 500, letterSpacing: 0.6, textTransform: 'uppercase', color: 'var(--text-primary)', fontFamily: 'inherit', padding: 0 }}>Files</button>
{breadcrumbs.slice(pathParts(rootPath).length).map((part, i) => {
const absIdx = pathParts(rootPath).length + i;
return (
<span key={`${part}-${absIdx}`} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ opacity: 0.4 }}>/</span>
<button type="button" onClick={() => loadFiles(pathTo(absIdx, breadcrumbs))} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 11, fontWeight: 500, letterSpacing: 0.6, textTransform: 'uppercase', color: absIdx === breadcrumbs.length - 1 ? 'var(--accent)' : 'var(--text-primary)', fontFamily: 'inherit', padding: 0 }}>{part}</button>
</span>
);
})}
</div>
</div>
</div>
{uploadProgress !== null && (
<div style={{ height: 2, background: 'var(--border)', flexShrink: 0 }}>
<div style={{ height: '100%', background: 'var(--accent)', width: `${uploadProgress}%`, transition: 'width 200ms' }} />
</div>
)}
{/* File table */}
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '18px 21px' }}>
{error && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>{error}</div>}
{loading ? (
<div className="card-empty-center">Loading</div>
) : entries.length === 0 ? (
<div className="card-empty-center">This folder is empty.</div>
) : (
<table className="table-sticky-head" style={{ width: '100%', tableLayout: 'fixed', borderCollapse: 'collapse' }}>
<colgroup>
<col style={{ width: 32 }} />
<col />
<col style={{ width: 90 }} />
<col style={{ width: 180 }} />
</colgroup>
<thead>
<tr>
<th style={{ ...TABLE_TH, textAlign: 'center', padding: '0 5px 12px' }}>
<input type="checkbox" checked={selected.size === entries.length && entries.length > 0} onChange={e => setSelected(e.target.checked ? new Set(entries.map(en => en.name)) : new Set())} />
</th>
<SortTh col="name" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} style={{ ...TABLE_TH, textAlign: 'left', paddingLeft: 5 }}>Name</SortTh>
<SortTh col="size" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} style={{ ...TABLE_TH, textAlign: 'right' }}>Size</SortTh>
<SortTh col="modified" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} style={{ ...TABLE_TH, textAlign: 'right' }}>Modified</SortTh>
</tr>
</thead>
<tbody>
{sortedEntries.map((entry, idx) => {
const isDir = entry.type === 'dir';
const childPath = entry.path;
const isRenaming = renamingEntry?.path === entry.path;
const rowBg = idx % 2 === 0 ? 'var(--file-row-alt-bg, rgba(255,255,255,0.02))' : 'transparent';
return (
<tr
key={`${entry.type}:${entry.path}`}
style={{ cursor: isDir ? 'pointer' : 'default' }}
onClick={() => isDir && loadFiles(childPath)}
onContextMenu={e => openCtxMenu(e, entry, childPath)}
>
<td style={{ ...TABLE_TD, background: rowBg, textAlign: 'center' }} onClick={e => { e.stopPropagation(); toggleSelect(entry.name); }}>
<input type="checkbox" checked={selected.has(entry.name)} onChange={() => toggleSelect(entry.name)} onClick={e => e.stopPropagation()} />
</td>
<td style={{ ...TABLE_TD, background: rowBg }}>
{isRenaming ? (
<form style={{ display: 'flex', gap: 6 }} onSubmit={renameEntry} onClick={e => e.stopPropagation()}>
<input autoFocus type="text" value={renameValue} onChange={e => setRenameValue(e.target.value)} disabled={Boolean(working)} onKeyDown={e => { if (e.key === 'Escape') setRenamingEntry(null); }} style={{ fontSize: 13, padding: '2px 8px', borderRadius: 4, border: '1px solid var(--border)', background: 'var(--card-bg-2)', color: 'var(--text-primary)', flex: 1, minWidth: 0 }} />
<LoadingButton type="submit" className="btn btn-outline" loading={working === `ren:${entry.path}`} disabled={!renameValue.trim()} loadingText="…">Save</LoadingButton>
<button type="button" className="btn btn-outline" onClick={() => setRenamingEntry(null)}>Cancel</button>
</form>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<FileIcon entry={entry} />
{isDir ? (
<button type="button" className="dashboard-inline-link" onClick={e => { e.stopPropagation(); loadFiles(childPath); }} style={{ fontSize: 13, fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'left' }}>{entry.name}</button>
) : (
<span style={{ fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{entry.name}</span>
)}
</div>
)}
</td>
<td style={{ ...TABLE_TD, background: rowBg, textAlign: 'right', fontSize: 12 }}>{isDir ? '—' : formatBytes(entry.size)}</td>
<td style={{ ...TABLE_TD, background: rowBg, textAlign: 'right', fontSize: 12 }}>{formatDate(entry.mtime)}</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
{/* Context menu — portal to escape stacking context */}
{ctxMenu && createPortal(
<div onClick={e => e.stopPropagation()} style={{ ...popupMenuStyle, position: 'fixed', left: ctxMenu.x, top: ctxMenu.y, zIndex: 9999, padding: '4px 0', minWidth: 160 }}>
{ctxMenu.entry?.type === 'dir' && (
<button style={ITEM_STYLE} onMouseEnter={e => e.currentTarget.style.background = 'var(--interactive-row-hover)'} onMouseLeave={e => e.currentTarget.style.background = 'none'} onClick={() => { loadFiles(ctxMenu.childPath); setCtxMenu(null); }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" /></svg>
Open
</button>
)}
{ctxMenu.entry?.type !== 'dir' && (
<button style={ITEM_STYLE} onMouseEnter={e => e.currentTarget.style.background = 'var(--interactive-row-hover)'} onMouseLeave={e => e.currentTarget.style.background = 'none'} onClick={() => { downloadFile(ctxMenu.entry); setCtxMenu(null); }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7,10 12,15 17,10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Download
</button>
)}
{ctxMenu.entry && !readOnly && (
<>
<div style={SEP} />
<button style={ITEM_STYLE} onMouseEnter={e => e.currentTarget.style.background = 'var(--interactive-row-hover)'} onMouseLeave={e => e.currentTarget.style.background = 'none'} onClick={() => { setRenamingEntry(ctxMenu.entry); setRenameValue(ctxMenu.entry.name); setCtxMenu(null); }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
Rename
</button>
</>
)}
{ctxMenu.entry && (
<>
<div style={SEP} />
<button style={ITEM_STYLE} onMouseEnter={e => e.currentTarget.style.background = 'var(--interactive-row-hover)'} onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={() => {
const items = selected.has(ctxMenu.entry.name)
? [...selected].map(n => { const en = entries.find(e => e.name === n); return { name: n, path: en?.path ?? joinVirtualPath(currentPath, n) }; })
: [{ name: ctxMenu.entry.name, path: ctxMenu.childPath }];
setClipboard({ items, mode: 'copy' }); setCtxMenu(null);
}}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
Copy
</button>
{!readOnly && (
<button style={ITEM_STYLE} onMouseEnter={e => e.currentTarget.style.background = 'var(--interactive-row-hover)'} onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={() => {
const items = selected.has(ctxMenu.entry.name)
? [...selected].map(n => { const en = entries.find(e => e.name === n); return { name: n, path: en?.path ?? joinVirtualPath(currentPath, n) }; })
: [{ name: ctxMenu.entry.name, path: ctxMenu.childPath }];
setClipboard({ items, mode: 'move' }); setCtxMenu(null);
}}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="14.47" y1="14.48" x2="20" y2="20"/><line x1="8.12" y1="8.12" x2="12" y2="12"/></svg>
Cut
</button>
)}
</>
)}
{clipboard && (
<>
<div style={SEP} />
<button style={ITEM_STYLE} onMouseEnter={e => e.currentTarget.style.background = 'var(--interactive-row-hover)'} onMouseLeave={e => e.currentTarget.style.background = 'none'} onClick={() => { pasteClipboard(); setCtxMenu(null); }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M16 4h2a2 2 0 012 2v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6a2 2 0 012-2h2"/><rect x="8" y="2" width="8" height="4" rx="1"/></svg>
Paste here{clipboard.items.length > 1 ? ` (${clipboard.items.length})` : ''}
</button>
</>
)}
{!readOnly && ctxMenu.entry && (
<>
<div style={SEP} />
<button style={{ ...ITEM_STYLE, color: 'var(--danger)' }} onMouseEnter={e => e.currentTarget.style.background = 'rgba(255,80,80,0.08)'} onMouseLeave={e => e.currentTarget.style.background = 'none'} onClick={() => { setCtxMenu(null); deleteEntry(ctxMenu.entry); }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="3,6 5,6 21,6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>
Delete
</button>
</>
)}
</div>,
document.body
)}
</div>
);
}
+15 -6
View File
@@ -15,17 +15,26 @@ export default function FilterDropdown({ value, onChange, options }) {
document.addEventListener('mousedown', handler); document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler);
}, [open]); }, [open]);
const current = options.find(o => o.value === value);
return ( return (
<div ref={ref} style={{ position: 'relative' }}> <div ref={ref} style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
<button className="btn btn-outline btn-sm" onClick={() => setOpen(o => !o)} style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <button
className="btn btn-outline"
onClick={() => setOpen(o => !o)}
style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
aria-label="Filter"
title="Filter"
>
<FilterIcon /> <FilterIcon />
{current?.label}
</button> </button>
{open && ( {open && (
<div style={{ position: 'absolute', top: 'calc(100% + 4px)', right: 0, background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, zIndex: 200, minWidth: 160, boxShadow: '0 4px 12px rgba(0,0,0,0.25)' }}> <div className="site-header-avatar-menu" style={{ top: 'calc(100% + 4px)', minWidth: 160 }}>
{options.map(opt => ( {options.map(opt => (
<button key={opt.value} onClick={() => { onChange(opt.value); setOpen(false); }} style={{ display: 'block', width: '100%', padding: '7px 14px', textAlign: 'left', background: value === opt.value ? 'rgba(245,165,35,0.08)' : 'transparent', fontSize: 13, color: value === opt.value ? 'var(--accent)' : 'var(--text-primary)', border: 'none', cursor: 'pointer' }}> <button
key={opt.value}
className="site-header-avatar-item"
onClick={() => { onChange(opt.value); setOpen(false); }}
style={value === opt.value ? { color: 'var(--accent)' } : undefined}
>
{opt.label} {opt.label}
</button> </button>
))} ))}
+53 -23
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import PageLoader from './PageLoader'; import PageLoader from './PageLoader';
import ProfileAvatar from './ProfileAvatar';
import { NavLink, Link, useNavigate, useLocation } from 'react-router-dom'; import { NavLink, Link, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
@@ -7,12 +8,12 @@ const ICONS = {
dashboard: <svg viewBox="0 0 16 16" fill="currentColor"><rect x="1" y="1" width="6" height="6" rx="1.5"/><rect x="9" y="1" width="6" height="6" rx="1.5"/><rect x="1" y="9" width="6" height="6" rx="1.5"/><rect x="9" y="9" width="6" height="6" rx="1.5"/></svg>, dashboard: <svg viewBox="0 0 16 16" fill="currentColor"><rect x="1" y="1" width="6" height="6" rx="1.5"/><rect x="9" y="1" width="6" height="6" rx="1.5"/><rect x="1" y="9" width="6" height="6" rx="1.5"/><rect x="9" y="9" width="6" height="6" rx="1.5"/></svg>,
requests: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><rect x="2" y="2" width="12" height="12" rx="1.5"/><line x1="5" y1="5.5" x2="11" y2="5.5"/><line x1="5" y1="8" x2="11" y2="8"/><line x1="5" y1="10.5" x2="8" y2="10.5"/></svg>, requests: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><rect x="2" y="2" width="12" height="12" rx="1.5"/><line x1="5" y1="5.5" x2="11" y2="5.5"/><line x1="5" y1="8" x2="11" y2="8"/><line x1="5" y1="10.5" x2="8" y2="10.5"/></svg>,
projects: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M1.5 5.5C1.5 4.67 2.17 4 3 4h2.5l1.5 2H13c.83 0 1.5.67 1.5 1.5v5.5c0 .83-.67 1.5-1.5 1.5H3c-.83 0-1.5-.67-1.5-1.5V5.5z"/></svg>, projects: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M1.5 5.5C1.5 4.67 2.17 4 3 4h2.5l1.5 2H13c.83 0 1.5.67 1.5 1.5v5.5c0 .83-.67 1.5-1.5 1.5H3c-.83 0-1.5-.67-1.5-1.5V5.5z"/></svg>,
fileSharing: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 2H4a1.5 1.5 0 00-1.5 1.5v9A1.5 1.5 0 004 14h8a1.5 1.5 0 001.5-1.5V6L9 2z"/><polyline points="9,2 9,6 13.5,6"/></svg>,
invoices: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><rect x="2" y="1.5" width="12" height="13" rx="1"/><line x1="8" y1="4" x2="8" y2="12"/><path d="M10 5.5H7a1.5 1.5 0 000 3h2a1.5 1.5 0 010 3H5.5"/></svg>, invoices: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><rect x="2" y="1.5" width="12" height="13" rx="1"/><line x1="8" y1="4" x2="8" y2="12"/><path d="M10 5.5H7a1.5 1.5 0 000 3h2a1.5 1.5 0 010 3H5.5"/></svg>,
notes: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><rect x="3" y="1.5" width="10" height="13" rx="1"/><line x1="5.5" y1="5" x2="10.5" y2="5"/><line x1="5.5" y1="7.5" x2="10.5" y2="7.5"/><line x1="5.5" y1="10" x2="8.5" y2="10"/></svg>, notes: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><rect x="3" y="1.5" width="10" height="13" rx="1"/><line x1="5.5" y1="5" x2="10.5" y2="5"/><line x1="5.5" y1="7.5" x2="10.5" y2="7.5"/><line x1="5.5" y1="10" x2="8.5" y2="10"/></svg>,
survey: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="1.5" y="9.5" width="3" height="5" rx="0.5"/><rect x="6.5" y="6" width="3" height="8.5" rx="0.5"/><rect x="11.5" y="2.5" width="3" height="12" rx="0.5"/></svg>, survey: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="1.5" y="9.5" width="3" height="5" rx="0.5"/><rect x="6.5" y="6" width="3" height="8.5" rx="0.5"/><rect x="11.5" y="2.5" width="3" height="12" rx="0.5"/></svg>,
brandBook: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M2.5 2.5h4a2 2 0 012 2v9a2 2 0 00-2-2h-4V2.5z"/><path d="M13.5 2.5h-4a2 2 0 00-2 2v9a2 2 0 012-2h4V2.5z"/></svg>, brandBook: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M2.5 2.5h4a2 2 0 012 2v9a2 2 0 00-2-2h-4V2.5z"/><path d="M13.5 2.5h-4a2 2 0 00-2 2v9a2 2 0 012-2h4V2.5z"/></svg>,
converter: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="1.5" y="1.5" width="13" height="13" rx="1.5"/><circle cx="5.5" cy="5.5" r="1.5"/><path d="M1.5 11.5l3.5-3.5 3 3L11 7.5l3 3"/></svg>, converter: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="1.5" y="1.5" width="13" height="13" rx="1.5"/><circle cx="5.5" cy="5.5" r="1.5"/><path d="M1.5 11.5l3.5-3.5 3 3L11 7.5l3 3"/></svg>,
reports: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M2.5 13.5h11"/><path d="M4.5 11V7.5"/><path d="M8 11V4.5"/><path d="M11.5 11V6"/></svg>,
passwords: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="7" width="10" height="7.5" rx="1"/><path d="M5 7V5.5a3 3 0 016 0V7"/><circle cx="8" cy="10.5" r="1" fill="currentColor" stroke="none"/></svg>, passwords: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="7" width="10" height="7.5" rx="1"/><path d="M5 7V5.5a3 3 0 016 0V7"/><circle cx="8" cy="10.5" r="1" fill="currentColor" stroke="none"/></svg>,
companies: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="1.5" y="5" width="13" height="9.5" rx="1"/><path d="M5.5 5V3a1 1 0 011-1h3a1 1 0 011 1v2"/><line x1="8" y1="5" x2="8" y2="14.5"/><line x1="1.5" y1="9" x2="14.5" y2="9"/></svg>, companies: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="1.5" y="5" width="13" height="9.5" rx="1"/><path d="M5.5 5V3a1 1 0 011-1h3a1 1 0 011 1v2"/><line x1="8" y1="5" x2="8" y2="14.5"/><line x1="1.5" y1="9" x2="14.5" y2="9"/></svg>,
users: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><circle cx="6" cy="5" r="2.5"/><path d="M1 14c0-2.76 2.24-5 5-5s5 2.24 5 5"/><circle cx="12.5" cy="5.5" r="2"/><path d="M12.5 10c1.93 0 3.5 1.57 3.5 3.5"/></svg>, users: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><circle cx="6" cy="5" r="2.5"/><path d="M1 14c0-2.76 2.24-5 5-5s5 2.24 5 5"/><circle cx="12.5" cy="5.5" r="2"/><path d="M12.5 10c1.93 0 3.5 1.57 3.5 3.5"/></svg>,
@@ -28,14 +29,14 @@ function TeamNav({ onNav }) {
const primaryLinks = [ const primaryLinks = [
{ to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard }, { to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
{ to: '/tasks', label: 'Tasks', icon: ICONS.requests }, { to: '/tasks', label: 'Tasks', icon: ICONS.requests },
{ to: '/invoices', label: 'Finances', icon: ICONS.invoices }, { to: '/finances', label: 'Finances', icon: ICONS.invoices },
{ to: '/file-sharing', label: 'File Sharing', icon: ICONS.fileSharing },
]; ];
const utilityLinks = [ const utilityLinks = [
{ to: '/survey-maker', label: 'Survey Maker', icon: ICONS.survey }, { to: '/survey-maker', label: 'Survey Maker', icon: ICONS.survey },
{ to: '/brand-book', label: 'Brand Book Maker', icon: ICONS.brandBook }, { to: '/brand-book', label: 'Brand Book Maker', icon: ICONS.brandBook },
{ to: '/converters', label: 'Image Converter', icon: ICONS.converter }, { to: '/converters', label: 'Image Converter', icon: ICONS.converter },
{ to: '/reports', label: 'Reports', icon: ICONS.reports },
{ to: '/fourge-passwords', label: 'Fourge Passwords', icon: ICONS.passwords }, { to: '/fourge-passwords', label: 'Fourge Passwords', icon: ICONS.passwords },
]; ];
@@ -73,8 +74,7 @@ function ClientNav({ onNav }) {
const links = [ const links = [
{ to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard }, { to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
{ to: '/tasks', label: 'Tasks', icon: ICONS.requests }, { to: '/tasks', label: 'Tasks', icon: ICONS.requests },
{ to: '/file-sharing', label: 'File Sharing', icon: ICONS.fileSharing }, { to: '/client-invoices', label: 'Invoices', icon: ICONS.invoices },
{ to: '/my-invoices', label: 'Invoices', icon: ICONS.invoices },
]; ];
return ( return (
@@ -94,8 +94,7 @@ function ExternalNav({ onNav }) {
const links = [ const links = [
{ to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard }, { to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
{ to: '/tasks', label: 'Tasks', icon: ICONS.requests }, { to: '/tasks', label: 'Tasks', icon: ICONS.requests },
{ to: '/my-invoices-sub', label: 'Invoices', icon: ICONS.invoices }, { to: '/subs-invoices', label: 'Invoices', icon: ICONS.invoices },
{ to: '/file-sharing', label: 'File Sharing', icon: ICONS.fileSharing },
{ to: '/survey-maker', label: 'Survey Maker', icon: ICONS.survey }, { to: '/survey-maker', label: 'Survey Maker', icon: ICONS.survey },
{ to: '/brand-book', label: 'Brand Book Maker', icon: ICONS.brandBook }, { to: '/brand-book', label: 'Brand Book Maker', icon: ICONS.brandBook },
{ to: '/converters', label: 'Image Converter', icon: ICONS.converter }, { to: '/converters', label: 'Image Converter', icon: ICONS.converter },
@@ -127,7 +126,7 @@ function getInitialTheme() {
return 'dark'; return 'dark';
} }
export default function Layout({ children }) { export default function Layout({ children, loading = false }) {
const { currentUser, logout } = useAuth(); const { currentUser, logout } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
@@ -144,20 +143,54 @@ export default function Layout({ children }) {
const timeOfDay = hour < 12 ? 'morning' : hour < 17 ? 'afternoon' : 'evening'; const timeOfDay = hour < 12 ? 'morning' : hour < 17 ? 'afternoon' : 'evening';
const firstName = currentUser?.name?.split(' ')[0] || ''; const firstName = currentUser?.name?.split(' ')[0] || '';
const isProfileRoute = location.pathname === '/profile' || location.pathname.startsWith('/profile/'); const isProfileRoute = location.pathname === '/profile' || location.pathname.startsWith('/profile/');
const isFileSharingRoute = location.pathname === '/file-sharing';
const isTaskDetailRoute = location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/requests/'); const isTaskDetailRoute = location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/requests/');
const isInvoiceDetailRoute =
location.pathname.startsWith('/finances/') ||
location.pathname.startsWith('/invoices/') ||
location.pathname.startsWith('/client-invoices/') ||
location.pathname.startsWith('/subs-invoices/') ||
location.pathname.startsWith('/my-invoices-sub/') ||
location.pathname.startsWith('/sub-invoices/');
const isRequestsRoute = location.pathname === '/tasks' || location.pathname === '/requests' || location.pathname === '/team/tasks' || isTaskDetailRoute; const isRequestsRoute = location.pathname === '/tasks' || location.pathname === '/requests' || location.pathname === '/team/tasks' || isTaskDetailRoute;
const isFinancesRoute = location.pathname === '/invoices' || location.pathname.startsWith('/invoices/') || location.pathname === '/sub-invoices' || location.pathname.startsWith('/sub-invoices/'); const isFinancesRoute =
const headerTitle = isProfileRoute ? 'Profile' : isFileSharingRoute ? 'File Sharing' : isFinancesRoute ? 'Finances' : isRequestsRoute && !isTaskDetailRoute ? 'Tasks & Projects' : !isTaskDetailRoute ? `Good ${timeOfDay}${firstName ? `, ${firstName}` : ''}` : null; location.pathname === '/finances' ||
location.pathname.startsWith('/finances/') ||
location.pathname === '/invoices' ||
location.pathname.startsWith('/invoices/') ||
location.pathname === '/sub-invoices' ||
location.pathname.startsWith('/sub-invoices/') ||
location.pathname === '/client-invoices' ||
location.pathname.startsWith('/client-invoices/') ||
location.pathname === '/my-invoices' ||
location.pathname.startsWith('/my-invoices/') ||
location.pathname === '/subs-invoices' ||
location.pathname.startsWith('/subs-invoices/') ||
location.pathname === '/my-invoices-sub' ||
location.pathname.startsWith('/my-invoices-sub/');
const isReportsRoute = location.pathname === '/reports';
const financeHeaderTitle = currentUser?.role === 'team' ? 'Finances' : 'Invoices';
const headerTitle = isProfileRoute ? 'Profile' : isFinancesRoute ? financeHeaderTitle : isReportsRoute ? 'Reports' : isRequestsRoute && !isTaskDetailRoute ? 'Tasks & Projects' : !isTaskDetailRoute && !isInvoiceDetailRoute ? `Good ${timeOfDay}${firstName ? `, ${firstName}` : ''}` : null;
const headerSubtitle = isProfileRoute const headerSubtitle = isProfileRoute
? 'Account details and security settings.' ? 'Account details and security settings.'
: isFileSharingRoute
? 'Browse, share and manage files.'
: isFinancesRoute : isFinancesRoute
? 'Invoices, expenses and subcontractor POs.' ? currentUser?.role === 'external'
? 'Track completed work, invoice history and payment status.'
: currentUser?.role === 'client'
? 'View invoices, payment status and receipt history.'
: 'Invoices, expenses and subcontractor POs.'
: isReportsRoute
? 'Track task versions, billing state and exportable summaries.'
: isRequestsRoute && !isTaskDetailRoute : isRequestsRoute && !isTaskDetailRoute
? 'Browse and manage all tasks and projects.' ? 'Browse and manage all tasks and projects.'
: isTaskDetailRoute ? null : "Here's what's happening today."; : isTaskDetailRoute || isInvoiceDetailRoute ? null : "Here's what's happening today.";
const detailBackTarget = isTaskDetailRoute
? '/tasks'
: currentUser?.role === 'team'
? '/finances'
: currentUser?.role === 'client'
? '/client-invoices'
: '/subs-invoices';
const detailBackLabel = isTaskDetailRoute ? 'Tasks & Projects' : financeHeaderTitle;
useEffect(() => { useEffect(() => {
document.documentElement.setAttribute('data-theme', theme); document.documentElement.setAttribute('data-theme', theme);
@@ -217,7 +250,7 @@ export default function Layout({ children }) {
return ( return (
<div className="app-layout"> <div className="app-layout">
{navigating && <PageLoader />} {(navigating || loading) && <PageLoader />}
{menuOpen && <div className="sidebar-overlay" onClick={() => setMenuOpen(false)} />} {menuOpen && <div className="sidebar-overlay" onClick={() => setMenuOpen(false)} />}
<aside className={`sidebar${menuOpen ? ' sidebar-open' : ''}`}> <aside className={`sidebar${menuOpen ? ' sidebar-open' : ''}`}>
@@ -245,11 +278,11 @@ export default function Layout({ children }) {
<main className="main-content"> <main className="main-content">
<div className="site-header"> <div className="site-header">
<div> <div>
{isTaskDetailRoute ? ( {isTaskDetailRoute || isInvoiceDetailRoute ? (
<div> <div>
<Link to="/tasks" className="dashboard-inline-link" style={{ display: 'inline-flex', alignItems: 'center', gap: 1, color: 'var(--text-muted)', textDecoration: 'none' }}> <Link to={detailBackTarget} className="dashboard-inline-link" style={{ display: 'inline-flex', alignItems: 'center', gap: 1, color: 'var(--text-muted)', textDecoration: 'none' }}>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ width: 24, height: 24, flexShrink: 0 }}><polyline points="10 4 6 8 10 12"/></svg> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ width: 24, height: 24, flexShrink: 0 }}><polyline points="10 4 6 8 10 12"/></svg>
<span style={{ fontSize: 24, fontWeight: 500, lineHeight: 1.2, letterSpacing: '-0.3px', position: 'relative', top: 2 }}>Tasks &amp; Projects</span> <span style={{ fontSize: 24, fontWeight: 500, lineHeight: 1.2, letterSpacing: '-0.3px', position: 'relative', top: 2 }}>{detailBackLabel}</span>
</Link> </Link>
<div className="site-header-sub" style={{ paddingLeft: 25 }}>Return back to previous page</div> <div className="site-header-sub" style={{ paddingLeft: 25 }}>Return back to previous page</div>
</div> </div>
@@ -336,10 +369,7 @@ export default function Layout({ children }) {
<div className="site-header-avatar-wrap" ref={avatarRef}> <div className="site-header-avatar-wrap" ref={avatarRef}>
<button className="site-header-avatar-btn" onClick={() => setAvatarOpen(o => !o)} aria-label="Account menu" style={{ overflow: 'hidden' }}> <button className="site-header-avatar-btn" onClick={() => setAvatarOpen(o => !o)} aria-label="Account menu" style={{ overflow: 'hidden' }}>
{currentUser?.avatar_url <ProfileAvatar profile={currentUser} name={currentUser?.name} size={68} fontSize={20} />
? <img src={currentUser.avatar_url} alt={currentUser.name} style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '50%' }} />
: <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4.4 3.6-7 8-7s8 2.6 8 7" fill="currentColor"/></svg>
}
</button> </button>
{avatarOpen && ( {avatarOpen && (
<div className="site-header-avatar-menu"> <div className="site-header-avatar-menu">
+1 -1
View File
@@ -30,7 +30,7 @@ export default function PageLoader({ label = 'Loading', progress = null, scope =
<> <>
<div style={{ height: 4, borderRadius: 2, background: 'var(--card-bg-2)', overflow: 'hidden', marginBottom: hasProgress ? 8 : 0 }}> <div style={{ height: 4, borderRadius: 2, background: 'var(--card-bg-2)', overflow: 'hidden', marginBottom: hasProgress ? 8 : 0 }}>
<div <div
className={hasProgress ? undefined : 'file-browser-progress-bar indeterminate'} className={hasProgress ? undefined : 'shared-progress-bar indeterminate'}
style={{ height: '100%', borderRadius: 2, background: 'var(--accent)', width: hasProgress ? `${progress}%` : undefined, transition: 'width 0.2s ease' }} style={{ height: '100%', borderRadius: 2, background: 'var(--accent)', width: hasProgress ? `${progress}%` : undefined, transition: 'width 0.2s ease' }}
/> />
</div> </div>
+68
View File
@@ -0,0 +1,68 @@
function normalizeName(value) {
return String(value || '').trim().toLowerCase();
}
export function resolveProfileAvatar({
avatarUrl = '',
profile = null,
profileId = '',
name = '',
profilesById = null,
profilesByName = null,
}) {
if (avatarUrl) return avatarUrl;
if (profile?.avatar_url) return profile.avatar_url;
if (profileId && profilesById?.get(profileId)?.avatar_url) return profilesById.get(profileId).avatar_url;
const normalized = normalizeName(name || profile?.name);
if (normalized && profilesByName?.get(normalized)?.avatar_url) return profilesByName.get(normalized).avatar_url;
return '';
}
export default function ProfileAvatar({
profile = null,
profileId = '',
name = '',
avatarUrl = '',
profilesById = null,
profilesByName = null,
size = 32,
fontSize = 12,
fallbackBg = 'var(--accent)',
fallbackColor = '#000',
alt = '',
style = {},
}) {
const resolvedUrl = resolveProfileAvatar({ avatarUrl, profile, profileId, name, profilesById, profilesByName });
const initials = String(name || profile?.name || '?')
.split(' ')
.map((part) => part[0])
.slice(0, 2)
.join('')
.toUpperCase();
if (resolvedUrl) {
return (
<div style={{ width: size, height: size, borderRadius: '50%', overflow: 'hidden', flexShrink: 0, ...style }}>
<img src={resolvedUrl} alt={alt || name || profile?.name || ''} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</div>
);
}
return (
<div
style={{
width: size,
height: size,
borderRadius: '50%',
background: fallbackBg,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
...style,
}}
>
<span style={{ fontSize, fontWeight: 600, color: fallbackColor, lineHeight: 1 }}>{initials || '?'}</span>
</div>
);
}
+86 -17
View File
@@ -8,6 +8,7 @@ const defaultDeadline = () => addDaysToDateOnly(getTodayDateOnlyEST(), 3);
const modalLabelStyle = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 6 }; const modalLabelStyle = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 6 };
const modalInputStyle = { fontSize: 13, padding: '0 5px', textAlign: 'left', lineHeight: 1 }; const modalInputStyle = { fontSize: 13, padding: '0 5px', textAlign: 'left', lineHeight: 1 };
const modalTextAreaStyle = { ...modalInputStyle, minHeight: 100, padding: '8px 5px', lineHeight: 1.4 }; const modalTextAreaStyle = { ...modalInputStyle, minHeight: 100, padding: '8px 5px', lineHeight: 1.4 };
const modalActionButtonStyle = { width: 132, justifyContent: 'center', flex: '0 0 132px' };
const emptyForm = (companyId = '') => ({ const emptyForm = (companyId = '') => ({
companyId, companyId,
project: '', project: '',
@@ -43,7 +44,11 @@ export default function RequestForm({
initialValues = null, initialValues = null,
lockedFields = [], lockedFields = [],
}) { }) {
const [form, setForm] = useState(() => ({ ...emptyForm(initialCompanyId), ...(initialValues || {}) })); const [form, setForm] = useState(() => ({
...emptyForm(initialCompanyId),
requestedBy: showRequester ? (currentUser?.id || '') : '',
...(initialValues || {}),
}));
const prevCompanyIdRef = useRef(initialValues?.companyId || initialCompanyId || null); const prevCompanyIdRef = useRef(initialValues?.companyId || initialCompanyId || null);
const [files, setFiles] = useState([]); const [files, setFiles] = useState([]);
const [existingProjects, setExistingProjects] = useState([]); const [existingProjects, setExistingProjects] = useState([]);
@@ -54,16 +59,17 @@ export default function RequestForm({
const [signFamilies, setSignFamilies] = useState([]); const [signFamilies, setSignFamilies] = useState([]);
const [signCount, setSignCount] = useState(''); const [signCount, setSignCount] = useState('');
const [signs, setSigns] = useState([]); const [signs, setSigns] = useState([]);
const [localError, setLocalError] = useState('');
const isBrandBook = form.serviceType === 'Brand Book'; const usesSignFields = form.serviceType === 'Brand Book';
useEffect(() => { useEffect(() => {
supabase.from('sign_families').select('name').order('sort_order').then(({ data }) => setSignFamilies((data || []).map(r => r.name))); supabase.from('sign_families').select('name').order('sort_order').then(({ data }) => setSignFamilies((data || []).map(r => r.name)));
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!isBrandBook) { setSignCount(''); setSigns([]); } if (!usesSignFields) { setSignCount(''); setSigns([]); }
}, [isBrandBook]); }, [usesSignFields]);
useEffect(() => { useEffect(() => {
const n = Math.max(0, parseInt(signCount) || 0); const n = Math.max(0, parseInt(signCount) || 0);
@@ -113,9 +119,15 @@ export default function RequestForm({
prevCompanyIdRef.current = companyId; prevCompanyIdRef.current = companyId;
}, [companyId]); // eslint-disable-line react-hooks/exhaustive-deps }, [companyId]); // eslint-disable-line react-hooks/exhaustive-deps
const set = (field) => (e) => setForm(f => ({ ...f, [field]: e.target.value })); const set = (field) => (e) => {
if (localError) setLocalError('');
setForm(f => ({ ...f, [field]: e.target.value }));
};
const setSign = (id, field, value) => setSigns(prev => prev.map(s => s.id === id ? { ...s, [field]: value } : s)); const setSign = (id, field, value) => {
if (localError) setLocalError('');
setSigns(prev => prev.map(s => s.id === id ? { ...s, [field]: value } : s));
};
const allProjectNames = [ const allProjectNames = [
...existingProjects.map(p => p.name), ...existingProjects.map(p => p.name),
@@ -143,18 +155,56 @@ export default function RequestForm({
}; };
const showCompanySelect = companies.length > 1 || showRequester; const showCompanySelect = companies.length > 1 || showRequester;
const requesterOptions = [
...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)` }] : []),
...companyUsers.filter(u => u.id !== currentUser?.id),
];
const handleSubmit = (e) => { const handleSubmit = (e) => {
e.preventDefault(); e.preventDefault();
const requestedByName = currentUser?.name || ''; setLocalError('');
const selectedRequester = showRequester
? requesterOptions.find(option => option.id === form.requestedBy) || null
: null;
const requestedBy = showRequester
? (selectedRequester?.id || '')
: (currentUser?.id || '');
const requestedByName = showRequester
? ((selectedRequester?.name || '').replace(/\s+\(You\)$/, '') || '')
: (currentUser?.name || '');
let normalizedSignCount = null;
let normalizedSigns = [];
if (usesSignFields) {
normalizedSignCount = parseInt(signCount, 10) || null;
const formData = new FormData(e.currentTarget);
normalizedSigns = Array.from({ length: normalizedSignCount || 0 }, (_, index) => {
const stateSign = signs[index] || {};
const domValue = formData.get(`sign_info_${index + 1}`);
const signName = typeof domValue === 'string'
? domValue.trim()
: String(stateSign.signName || '').trim();
return {
id: stateSign.id || `sign-${index + 1}`,
signName,
signFamily: stateSign.signFamily || '',
};
}).filter(sign => sign.signName || sign.signFamily);
if ((normalizedSignCount || 0) > 0 && normalizedSigns.length !== normalizedSignCount) {
setLocalError('Please fill in the sign information for each sign before submitting.');
return;
}
}
onSubmit( onSubmit(
{ {
...form, ...form,
companyId, companyId,
requestedBy: currentUser?.id || '', requestedBy,
requestedByName, requestedByName,
signCount: isBrandBook ? (parseInt(signCount) || null) : null, signCount: usesSignFields ? normalizedSignCount : null,
signs: isBrandBook ? signs : [], signs: usesSignFields ? normalizedSigns : [],
}, },
files, files,
existingProjects existingProjects
@@ -214,6 +264,24 @@ export default function RequestForm({
</div> </div>
</div> </div>
{showRequester && (
<div className="form-group">
<label style={modalLabelStyle}>Requested By *</label>
<select
value={form.requestedBy}
onChange={set('requestedBy')}
required
disabled={!companyId}
style={modalInputStyle}
>
<option value="">{companyId ? 'Select requester...' : 'Select company first'}</option>
{requesterOptions.map(user => (
<option key={user.id} value={user.id}>{user.name}</option>
))}
</select>
</div>
)}
{/* Row 3: Title/Location + Mark as Hot inline */} {/* Row 3: Title/Location + Mark as Hot inline */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 12, alignItems: 'end', marginBottom: 16 }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 12, alignItems: 'end', marginBottom: 16 }}>
<div className="form-group" style={{ marginBottom: 0 }}> <div className="form-group" style={{ marginBottom: 0 }}>
@@ -226,7 +294,7 @@ export default function RequestForm({
</label> </label>
</div> </div>
{isBrandBook && ( {usesSignFields && (
<div className="form-group"> <div className="form-group">
<label style={modalLabelStyle}>Sign Count *</label> <label style={modalLabelStyle}>Sign Count *</label>
<input <input
@@ -242,7 +310,7 @@ export default function RequestForm({
</div> </div>
)} )}
{isBrandBook && signs.length > 0 && ( {usesSignFields && signs.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
{signs.map((sign, i) => ( {signs.map((sign, i) => (
<div key={sign.id} style={{ border: '1px solid var(--border)', borderRadius: 6, padding: '10px 12px' }}> <div key={sign.id} style={{ border: '1px solid var(--border)', borderRadius: 6, padding: '10px 12px' }}>
@@ -251,6 +319,7 @@ export default function RequestForm({
<label style={modalLabelStyle}>Sign Information *</label> <label style={modalLabelStyle}>Sign Information *</label>
<input <input
type="text" type="text"
name={`sign_info_${i + 1}`}
placeholder="e.g. Main Entry, Drive Thru" placeholder="e.g. Main Entry, Drive Thru"
value={sign.signName} value={sign.signName}
onChange={e => setSign(sign.id, 'signName', e.target.value)} onChange={e => setSign(sign.id, 'signName', e.target.value)}
@@ -264,19 +333,19 @@ export default function RequestForm({
)} )}
<div className="form-group"> <div className="form-group">
<label style={modalLabelStyle}>{isBrandBook ? 'Notes' : 'Description'} *</label> <label style={modalLabelStyle}>{usesSignFields ? 'Notes' : 'Description'} *</label>
<textarea placeholder="Notes on the request..." value={form.description} onChange={set('description')} style={modalTextAreaStyle} required /> <textarea placeholder="Notes on the request..." value={form.description} onChange={set('description')} style={modalTextAreaStyle} required />
</div> </div>
<FileAttachment files={files} onChange={setFiles} /> <FileAttachment files={files} onChange={setFiles} />
{error && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}> {error}</div>} {(localError || error) && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}> {localError || error}</div>}
<div className="action-buttons" style={{ justifyContent: 'flex-end' }}> <div className="action-buttons modal-action-row" style={{ justifyContent: 'flex-end' }}>
<button type="submit" className="btn btn-outline" disabled={saving}> <button type="submit" className="btn btn-outline" disabled={saving} style={modalActionButtonStyle}>
{saving ? 'Submitting...' : submitLabel} {saving ? 'Submitting...' : submitLabel}
</button> </button>
{onCancel && <button type="button" className="btn btn-outline" onClick={onCancel}>Cancel</button>} {onCancel && <button type="button" className="btn btn-outline" onClick={onCancel} style={modalActionButtonStyle}>Cancel</button>}
</div> </div>
</form> </form>
); );
@@ -0,0 +1,112 @@
import SortTh from './SortTh';
function fmt(val) {
return `$${Number(val || 0).toFixed(2)}`;
}
function inferItemType(item) {
const match = String(item?.description || '').match(/\bR(\d{2})\b/i);
if (match) {
return Number(match[1]) <= 0
? { label: 'New', badgeClass: 'badge-initial' }
: { label: 'Revision', badgeClass: 'badge-client_revision' };
}
if (item?.task_id) return { label: 'Task', badgeClass: 'badge-in_progress' };
return { label: 'Other', badgeClass: 'badge-initial' };
}
export default function SubcontractorInvoiceDetailView({
error,
headerActions,
leftCardTitle,
leftCardBody,
rightCardTitle,
rightCardBody,
sortKey,
sortDir,
onSort,
items,
notes,
total,
}) {
return (
<div className="invoice-detail-shell">
{error ? <div className="notification notification-info" style={{ marginBottom: 16, flexShrink: 0 }}>{error}</div> : null}
<div className="invoice-detail-stack">
<div className="invoice-detail-summary-grid">
<div className="card invoice-detail-card">
<div className="invoice-detail-section-title">{leftCardTitle}</div>
{leftCardBody}
</div>
<div className="card invoice-detail-card">
<div className="invoice-detail-card-header">
<div className="invoice-detail-section-title" style={{ marginBottom: 0 }}>{rightCardTitle}</div>
{headerActions ? <div className="invoice-detail-card-actions">{headerActions}</div> : null}
</div>
{rightCardBody}
</div>
</div>
<div className="card invoice-detail-card invoice-detail-line-items-card">
<div className="invoice-detail-section-title">Line Items</div>
{items.length === 0 ? (
<div className="card-empty-center" style={{ minHeight: 120 }}>No line items.</div>
) : (
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ overflowY: 'auto' }}>
<table className="table-sticky-head invoice-detail-table" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '12%' }} />
<col style={{ width: '46%' }} />
<col style={{ width: '14%' }} />
<col style={{ width: '14%' }} />
<col style={{ width: '14%' }} />
</colgroup>
<thead>
<tr>
<SortTh col="type" sortKey={sortKey} sortDir={sortDir} onSort={onSort}>Type</SortTh>
<SortTh col="description" sortKey={sortKey} sortDir={sortDir} onSort={onSort}>Description</SortTh>
<SortTh col="quantity" sortKey={sortKey} sortDir={sortDir} onSort={onSort} style={{ textAlign: 'center' }}>Qty</SortTh>
<SortTh col="unit_price" sortKey={sortKey} sortDir={sortDir} onSort={onSort} style={{ textAlign: 'right' }}>Unit Price</SortTh>
<SortTh col="line_total" sortKey={sortKey} sortDir={sortDir} onSort={onSort} style={{ textAlign: 'right' }}>Total</SortTh>
</tr>
</thead>
<tbody>
{items.map((item) => {
const itemType = inferItemType(item);
return (
<tr key={item.id}>
<td style={{ padding: '5px 0' }}>
<span className={`badge ${itemType.badgeClass}`}>{itemType.label}</span>
</td>
<td className="invoice-detail-cell">{item.description}</td>
<td className="invoice-detail-cell" style={{ textAlign: 'center' }}>{item.quantity}</td>
<td className="invoice-detail-cell" style={{ textAlign: 'right' }}>{fmt(item.unit_price)}</td>
<td className="invoice-detail-cell" style={{ textAlign: 'right', fontWeight: 400 }}>
{fmt(Number(item.unit_price || 0) * Number(item.quantity || 1))}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
<div className="invoice-detail-total-row">
<div style={{ textAlign: 'right' }}>
<div className="invoice-detail-total-label">Total</div>
<div className="invoice-detail-total-value">{fmt(total)}</div>
</div>
</div>
</div>
{notes ? (
<div className="card invoice-detail-card">
<div className="invoice-detail-section-title">Notes</div>
<p className="invoice-detail-notes">{notes}</p>
</div>
) : null}
</div>
</div>
);
}
+562
View File
@@ -0,0 +1,562 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import LoadingButton from './LoadingButton';
import { supabase } from '../lib/supabase';
import { useAuth } from '../context/AuthContext';
import { sendEmail } from '../lib/email';
import { isCompletedVersionEligible } from '../lib/invoiceVersionRules';
const INVOICE_TODAY = new Date().toISOString().split('T')[0];
const REVISION_RATE = 30;
const FIELD_LABEL_STYLE = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, display: 'block', marginBottom: 4 };
const FIELD_INPUT_STYLE = { minHeight: 42, margin: 0 };
const FINANCE_MODAL_INPUT_STYLE = {
...FIELD_INPUT_STYLE,
width: '100%',
padding: '0 10px',
fontSize: 13,
color: 'var(--text-primary)',
background: 'var(--card-bg-2)',
border: '1px solid var(--border)',
borderRadius: 6,
fontFamily: 'inherit',
boxSizing: 'border-box',
};
const FINANCE_MODAL_TEXTAREA_STYLE = {
...FINANCE_MODAL_INPUT_STYLE,
minHeight: 72,
padding: '10px',
resize: 'vertical',
};
const FINANCE_MODAL_LINE_ITEM_INPUT_STYLE = {
...FINANCE_MODAL_INPUT_STYLE,
minHeight: 32,
padding: '0 8px',
};
const FINANCE_MODAL_NUMBER_INPUT_STYLE = {
...FINANCE_MODAL_LINE_ITEM_INPUT_STYLE,
fontVariantNumeric: 'tabular-nums',
};
const FINANCE_MODAL_TOTAL_VALUE_STYLE = {
fontSize: 24,
fontWeight: 500,
lineHeight: 1.1,
color: 'var(--accent)',
fontVariantNumeric: 'tabular-nums',
};
function asArray(value) {
if (Array.isArray(value)) return value;
if (value == null) return [];
return typeof value === 'object' ? [value] : [];
}
function versionLabel(version = 0) {
return `R${String(version).padStart(2, '0')}`;
}
function parseVersionFromDescription(description = '') {
const match = String(description).match(/\bR(\d{2})\b/i);
return match ? Number(match[1]) : 0;
}
function subcontractorVersionRate(version, newWorkRate) {
if (version <= 0) return newWorkRate;
if (version === 1) return 0;
return REVISION_RATE;
}
function newItem(description = '', unitPrice = '', quantity = 1, taskId = null, isRevision = false, versionNumber = 0, lineKey = null) {
return {
id: crypto.randomUUID(),
description,
unit_price: unitPrice,
quantity,
task_id: taskId,
is_revision: isRevision,
version_number: versionNumber,
line_key: lineKey || (taskId ? `${taskId}:${versionNumber}` : crypto.randomUUID()),
};
}
function genNumber(count) {
const year = new Date().getFullYear();
return `INVSUB-${year}-${String(count + 1).padStart(3, '0')}`;
}
export default function SubcontractorInvoiceForm({ embedded = false, onCancel, onSubmitted }) {
const navigate = useNavigate();
const { currentUser } = useAuth();
const [invoiceNumber, setInvoiceNumber] = useState('');
const [completedTasks, setCompletedTasks] = useState([]);
const [loadingTasks, setLoadingTasks] = useState(true);
const [items, setItems] = useState([newItem()]);
const [notes, setNotes] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [lineItemsPulse, setLineItemsPulse] = useState(false);
const dragItem = useRef(null);
const lineItemsRef = useRef(null);
const rate = Number(currentUser?.brand_book_rate || 60);
useEffect(() => {
async function load() {
if (!currentUser?.id) {
setCompletedTasks([]);
setLoadingTasks(false);
return;
}
setLoadingTasks(true);
setError('');
try {
const [{ data: tasks, error: tasksError }, { data: existingInvoices, error: invoicesError }, { data: nextNum, error: nextNumError }] = await Promise.all([
supabase
.from('tasks')
.select('id, title, status, current_version, project:projects(name)')
.in('status', ['not_started', 'in_progress', 'on_hold', 'client_review', 'client_approved', 'invoiced', 'paid'])
.eq('assigned_to', currentUser.id)
.order('title'),
supabase
.from('subcontractor_invoices')
.select('id, status, items:subcontractor_invoice_items(task_id, description)')
.in('status', ['submitted', 'paid']),
supabase.rpc('get_next_sub_invoice_number'),
]);
if (tasksError) throw tasksError;
if (invoicesError) throw invoicesError;
if (nextNumError) throw nextNumError;
setInvoiceNumber(nextNum || genNumber(0));
const invoicedUnitKeys = new Set(
(existingInvoices || []).flatMap((inv) => asArray(inv.items).map((item) => {
if (!item.task_id) return null;
return `${item.task_id}:${parseVersionFromDescription(item.description)}`;
}).filter(Boolean))
);
const taskIds = (tasks || []).map((task) => task.id).filter(Boolean);
const { data: submissionRows, error: submissionsError } = taskIds.length > 0
? await supabase
.from('submissions')
.select('task_id, deliveries(version_number, sent_by, sent_at)')
.in('task_id', taskIds)
: { data: [], error: null };
if (submissionsError) throw submissionsError;
const deliveriesByTask = new Map();
for (const row of (submissionRows || [])) {
const taskId = row.task_id;
if (!taskId) continue;
const bucket = deliveriesByTask.get(taskId) || new Map();
for (const delivery of asArray(row.deliveries)) {
if ((delivery.sent_by || '').trim().toLowerCase() !== (currentUser.name || '').trim().toLowerCase()) continue;
const version = Number(delivery.version_number || 0);
if (!bucket.has(version)) bucket.set(version, delivery);
}
deliveriesByTask.set(taskId, bucket);
}
const units = (tasks || []).flatMap((task) => {
const versions = [...(deliveriesByTask.get(task.id)?.keys() || [])].sort((a, b) => a - b);
return versions
.filter((version) => isCompletedVersionEligible(task, version))
.map((version) => {
const key = `${task.id}:${version}`;
return {
key,
task_id: task.id,
version_number: version,
is_revision: version > 0,
title: task.title,
project_name: task.project?.name || '',
description: `${task.project?.name ? `${task.project.name}` : ''}${task.title} ${versionLabel(version)}`,
rate: subcontractorVersionRate(version, rate),
};
});
}).filter((unit) => !invoicedUnitKeys.has(unit.key));
setCompletedTasks(units);
} catch (err) {
console.error('Failed to load subcontractor invoice tasks:', err);
setCompletedTasks([]);
setError(err?.message || 'Could not load completed tasks.');
} finally {
setLoadingTasks(false);
}
}
load();
}, [currentUser?.id, currentUser?.name, rate]);
const addedTaskIds = useMemo(() => new Set(items.map((item) => item.line_key).filter(Boolean)), [items]);
const focusLineItems = () => {
setLineItemsPulse(true);
lineItemsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
window.setTimeout(() => setLineItemsPulse(false), 1200);
};
const addTask = (task) => {
if (addedTaskIds.has(task.key)) return;
const toAdd = [newItem(task.description, task.rate, 1, task.task_id, task.is_revision, task.version_number, task.key)];
setItems((prev) => {
if (prev.length === 1 && !prev[0].description && !prev[0].unit_price) return toAdd;
return [...prev, ...toAdd];
});
focusLineItems();
};
const updateItem = (id, field, value) => {
setItems((prev) => prev.map((item) => (item.id === id ? { ...item, [field]: value } : item)));
};
const removeItem = (id) => setItems((prev) => prev.filter((item) => item.id !== id));
const handleDrop = (targetIndex) => {
if (dragItem.current === null || dragItem.current === targetIndex) {
dragItem.current = null;
return;
}
setItems((prev) => {
const next = [...prev];
const [moved] = next.splice(dragItem.current, 1);
next.splice(targetIndex, 0, moved);
return next;
});
dragItem.current = null;
};
const handleCancel = () => {
if (saving) return;
if (onCancel) {
onCancel();
return;
}
navigate('/subs-invoices');
};
const total = items.reduce((sum, item) => sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0), 0);
const handleSubmit = async () => {
const valid = items.filter((item) => String(item.description).trim());
if (!valid.length) {
setError('Add at least one line item.');
return;
}
setSaving(true);
setError('');
try {
const { data: inv, error: invErr } = await supabase
.from('subcontractor_invoices')
.insert({
profile_id: currentUser.id,
invoice_number: invoiceNumber.trim(),
status: 'submitted',
notes: notes.trim(),
submitted_at: new Date().toISOString(),
})
.select()
.single();
if (invErr) throw invErr;
const { error: itemsErr } = await supabase.from('subcontractor_invoice_items').insert(
valid.map((item, idx) => ({
invoice_id: inv.id,
task_id: item.task_id || null,
description: String(item.description).trim(),
quantity: Number(item.quantity) || 1,
unit_price: Number(item.unit_price) || 0,
sort_order: idx,
}))
);
if (itemsErr) throw itemsErr;
const invoiceTotal = valid.reduce((sum, item) => sum + (Number(item.unit_price) || 0) * (Number(item.quantity) || 1), 0);
sendEmail('subcontractor_invoice_submitted', 'hello@fourgebranding.com', {
subName: currentUser.name,
subEmail: currentUser.email || '',
invoiceNumber: inv.invoice_number,
total: invoiceTotal.toFixed(2),
invoiceId: inv.id,
}).catch((err) => console.error('Sub invoice notification failed:', err));
if (onSubmitted) {
await onSubmitted(inv);
} else {
navigate(`/subs-invoices/${inv.id}`);
}
} catch (err) {
setError(err?.message || 'Failed to submit invoice.');
setSaving(false);
}
};
return (
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, gap: 0 }}>
{!embedded && (
<>
<button className="back-link" onClick={handleCancel}> Back to Invoices</button>
<div className="page-header">
<div>
<div className="page-title">New Invoice</div>
<div className="page-subtitle">
Invoice date: {new Date(INVOICE_TODAY).toLocaleDateString()} · {invoiceNumber}
</div>
</div>
</div>
</>
)}
{embedded ? (
<div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, flex: '0 0 260px', overflowY: 'auto' }}>
<div style={{ ...FIELD_LABEL_STYLE, marginBottom: 3 }}>New Invoice</div>
<div>
<div style={{ ...FIELD_LABEL_STYLE, marginBottom: 3 }}>Invoice Date</div>
<input type="text" value={new Date(INVOICE_TODAY).toLocaleDateString()} readOnly style={FINANCE_MODAL_INPUT_STYLE} />
</div>
<div>
<div style={{ ...FIELD_LABEL_STYLE, marginBottom: 3 }}>Invoice Number</div>
<input type="text" value={invoiceNumber} onChange={(e) => setInvoiceNumber(e.target.value)} style={FINANCE_MODAL_INPUT_STYLE} />
</div>
<div>
<div style={{ ...FIELD_LABEL_STYLE, marginBottom: 3 }}>Notes</div>
<textarea
placeholder="Additional notes for the team..."
value={notes}
onChange={(e) => setNotes(e.target.value)}
style={FINANCE_MODAL_TEXTAREA_STYLE}
/>
</div>
<div style={{ marginTop: 'auto', paddingTop: 8 }}>
<div style={{ ...FIELD_LABEL_STYLE, marginBottom: 3 }}>Total</div>
<div style={FINANCE_MODAL_TOTAL_VALUE_STYLE}>${total.toFixed(2)}</div>
</div>
{error && <div style={{ fontSize: 12, color: 'var(--danger)' }}>{error}</div>}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, flex: 1, minHeight: 0, overflowY: 'auto' }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
<div style={{ ...FIELD_LABEL_STYLE, marginBottom: 3 }}>Completed Tasks</div>
{completedTasks.length > 0 && !loadingTasks && (
<button
type="button"
className="btn btn-outline"
style={{ fontSize: 11 }}
onClick={() => completedTasks.forEach((task) => { if (!addedTaskIds.has(task.key)) addTask(task); })}
>
+ All
</button>
)}
</div>
{loadingTasks ? (
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>Loading</div>
) : completedTasks.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>No completed tasks available to invoice.</div>
) : (
completedTasks.map((task) => {
const alreadyAdded = addedTaskIds.has(task.key);
return (
<div key={task.key} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 10px', background: 'var(--card-bg-2)', borderRadius: 4, border: '1px solid var(--border)', marginBottom: 4 }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span className={`badge ${task.is_revision ? 'badge-client_revision' : 'badge-initial'}`}>
{task.is_revision ? 'Revision' : 'New'}
</span>
<div style={{ fontSize: 12 }}>{task.project_name ? `${task.project_name}` : ''}{task.title} · {versionLabel(task.version_number)}</div>
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
{task.version_number === 0
? `$${rate.toFixed(2)}`
: task.version_number === 1
? 'Free'
: `$${REVISION_RATE.toFixed(2)}`}
</div>
</div>
<button
type="button"
className="btn btn-outline"
style={{ fontSize: 11 }}
onClick={() => !alreadyAdded && addTask(task)}
disabled={alreadyAdded}
>
{alreadyAdded ? 'Added' : '+ Add'}
</button>
</div>
);
})
)}
</div>
<div
ref={lineItemsRef}
style={{
flex: 1,
border: '1px solid var(--border)',
borderColor: lineItemsPulse ? 'var(--accent)' : 'var(--border)',
borderRadius: 8,
padding: 12,
boxShadow: lineItemsPulse ? '0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent)' : 'none',
transition: 'border-color 180ms ease, box-shadow 180ms ease',
}}
>
<div style={{ ...FIELD_LABEL_STYLE, marginBottom: 6 }}>Line Items ({items.filter((item) => String(item.description || '').trim()).length})</div>
<div style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 60px 100px 90px 28px', gap: 6, marginBottom: 6 }}>
{['', 'Type', 'Description', 'Qty', 'Unit Price', 'Total', ''].map((header, index) => (
<div key={index} style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.5, color: 'var(--text-muted)', textAlign: index > 3 ? 'right' : 'left' }}>{header}</div>
))}
</div>
{items.map((item, index) => (
<div
key={item.id}
onDragOver={(e) => e.preventDefault()}
onDrop={() => handleDrop(index)}
style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 60px 100px 90px 28px', gap: 6, alignItems: 'center', marginBottom: 4 }}
>
<div draggable onDragStart={(e) => { dragItem.current = index; e.dataTransfer.effectAllowed = 'move'; }} style={{ cursor: 'grab', color: 'var(--text-muted)', fontSize: 12, textAlign: 'center', userSelect: 'none' }}></div>
<span className={`badge ${item.is_revision ? 'badge-client_revision' : 'badge-initial'}`}>
{item.is_revision ? 'Revision' : 'New'}
</span>
<input style={FINANCE_MODAL_LINE_ITEM_INPUT_STYLE} type="text" placeholder="Description…" value={item.description} onChange={(e) => updateItem(item.id, 'description', e.target.value)} />
<input style={{ ...FINANCE_MODAL_NUMBER_INPUT_STYLE, textAlign: 'center' }} type="number" min="0.5" step="0.5" value={item.quantity} onChange={(e) => updateItem(item.id, 'quantity', e.target.value)} />
<input style={{ ...FINANCE_MODAL_NUMBER_INPUT_STYLE, textAlign: 'right' }} type="number" min="0" step="0.01" placeholder="0.00" value={item.unit_price} onChange={(e) => updateItem(item.id, 'unit_price', e.target.value)} />
<div style={{ minHeight: 32, display: 'flex', alignItems: 'center', justifyContent: 'flex-end', textAlign: 'right', fontSize: 13, color: 'var(--text-primary)', paddingRight: 2, fontVariantNumeric: 'tabular-nums' }}>${((Number(item.quantity) || 0) * (Number(item.unit_price) || 0)).toFixed(2)}</div>
<button type="button" onClick={() => removeItem(item.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--danger)', fontSize: 14, padding: 2 }}></button>
</div>
))}
<button type="button" className="btn btn-outline" style={{ marginTop: 8, fontSize: 12 }} onClick={() => { setItems((prev) => [...prev, newItem()]); focusLineItems(); }}>+ Line Item</button>
</div>
</div>
</div>
) : (
<>
{error && <div className="notification notification-info" style={{ marginBottom: 16 }}>{error}</div>}
<div className="card" style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<div className="card-title" style={{ marginBottom: 0 }}>Completed Tasks</div>
{completedTasks.length > 0 && !loadingTasks && (
<button
className="btn btn-outline btn-sm"
onClick={() => completedTasks.forEach((task) => { if (!addedTaskIds.has(task.key)) addTask(task); })}
>
+ Add All
</button>
)}
</div>
{loadingTasks ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading...</p>
) : completedTasks.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>No completed tasks available to invoice.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{completedTasks.map((task) => {
const alreadyAdded = addedTaskIds.has(task.key);
return (
<div key={task.key} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 400 }}>
{task.project_name ? `${task.project_name}` : ''}{task.title} · {versionLabel(task.version_number)}
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
{task.version_number === 0
? `New Book · $${rate.toFixed(2)}`
: task.version_number === 1
? `Revision ${versionLabel(task.version_number)} · Free`
: `Revision ${versionLabel(task.version_number)} · $${REVISION_RATE.toFixed(2)}`}
</div>
</div>
<button
className={`btn btn-sm ${alreadyAdded ? 'btn-outline' : 'btn-primary'}`}
onClick={() => !alreadyAdded && addTask(task)}
disabled={alreadyAdded}
>
{alreadyAdded ? 'Added' : '+ Add'}
</button>
</div>
);
})}
</div>
)}
</div>
<div
ref={lineItemsRef}
className="card"
style={{
marginBottom: 24,
borderColor: lineItemsPulse ? 'var(--accent)' : 'var(--border)',
boxShadow: lineItemsPulse ? '0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent)' : 'none',
transition: 'border-color 180ms ease, box-shadow 180ms ease',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<div className="card-title" style={{ marginBottom: 0 }}>Line Items ({items.filter((item) => String(item.description || '').trim()).length})</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 80px 120px 120px 40px', gap: 8, marginBottom: 8 }}>
{['', 'Type', 'Description', 'Qty / Hrs', 'Rate', 'Total', ''].map((header, index) => (
<div key={index} style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', textAlign: index > 3 ? 'right' : 'left' }}>{header}</div>
))}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{items.map((item, index) => (
<div
key={item.id}
onDragOver={(e) => e.preventDefault()}
onDrop={() => handleDrop(index)}
style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 80px 120px 120px 40px', gap: 8, alignItems: 'center' }}
>
<div draggable onDragStart={(e) => { dragItem.current = index; e.dataTransfer.effectAllowed = 'move'; }} style={{ cursor: 'grab', color: 'var(--text-muted)', fontSize: 14, textAlign: 'center', userSelect: 'none' }}></div>
<span className={`badge ${item.is_revision ? 'badge-client_revision' : 'badge-initial'}`}>
{item.is_revision ? 'Revision' : 'New'}
</span>
<input type="text" placeholder="Description..." value={item.description} onChange={(e) => updateItem(item.id, 'description', e.target.value)} style={{ margin: 0 }} />
<input type="number" min="0.5" step="0.5" value={item.quantity} onChange={(e) => updateItem(item.id, 'quantity', e.target.value)} style={{ margin: 0, textAlign: 'center' }} />
<input type="number" min="0" step="0.01" placeholder="0.00" value={item.unit_price} onChange={(e) => updateItem(item.id, 'unit_price', e.target.value)} style={{ margin: 0, textAlign: 'right' }} />
<div style={{ textAlign: 'right', fontSize: 14, fontWeight: 400, color: 'var(--text-primary)', paddingRight: 4 }}>
${((Number(item.quantity) || 0) * (Number(item.unit_price) || 0)).toFixed(2)}
</div>
<button onClick={() => removeItem(item.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--danger)', fontSize: 16, padding: 4 }}></button>
</div>
))}
</div>
<button className="btn btn-outline btn-sm" style={{ marginTop: 12 }} onClick={() => { setItems((prev) => [...prev, newItem()]); focusLineItems(); }}>
+ Add Line Item
</button>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 20, paddingTop: 16, borderTop: '1px solid var(--border)' }}>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>Total</div>
<div style={{ fontSize: 26, fontWeight: 400, color: 'var(--accent)' }}>${total.toFixed(2)}</div>
</div>
</div>
</div>
<div className="card" style={{ marginBottom: 24 }}>
<div className="card-title">Notes</div>
<textarea
placeholder="Additional notes for the team..."
value={notes}
onChange={(e) => setNotes(e.target.value)}
style={{ minHeight: 80 }}
/>
</div>
</>
)}
<div className={embedded ? 'modal-action-row' : 'action-buttons'} style={embedded ? { paddingTop: 14, marginTop: 14, flexShrink: 0 } : undefined}>
<LoadingButton className="btn btn-primary" onClick={handleSubmit} loading={saving} loadingText="Submitting...">
Submit Invoice
</LoadingButton>
<button className="btn btn-outline" onClick={handleCancel} disabled={saving}>Cancel</button>
</div>
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
import { useCallback, useState } from 'react';
import { useRefetchOnFocus } from './useRefetchOnFocus';
import { useRealtimeSubscription } from './useRealtimeSubscription';
export function useLiveRefresh(tables = []) {
const [refreshKey, setRefreshKey] = useState(0);
const refresh = useCallback(() => setRefreshKey((key) => key + 1), []);
useRefetchOnFocus(refresh);
useRealtimeSubscription(tables, refresh);
return { refreshKey, refresh };
}
+254 -286
View File
@@ -42,15 +42,58 @@
--overlay-scrim: rgba(0,0,0,0.58); --overlay-scrim: rgba(0,0,0,0.58);
--popup-shadow: 0 24px 64px rgba(0,0,0,0.5); --popup-shadow: 0 24px 64px rgba(0,0,0,0.5);
--avatar-inner-ring: #111111; --avatar-inner-ring: #111111;
--bg-grid-line: rgba(245, 165, 35, 0.07);
--bg-grid-glow: rgba(245, 165, 35, 0.08);
--bg-grid-streak: rgba(255, 252, 244, 1);
--danger: #ef4444; --danger: #ef4444;
--success: #22c55e; --success: #22c55e;
--table-scroll-thumb: rgba(0,0,0,0.1); --table-scroll-thumb: rgba(0,0,0,0.1);
--table-scroll-thumb-hover: rgba(0,0,0,0.2); --table-scroll-thumb-hover: rgba(0,0,0,0.2);
} }
@keyframes ui-fade-up { @keyframes ambient-grid-flow {
from { opacity: 0; transform: translateY(4px); } 0% {
to { opacity: 1; transform: translateY(0); } background-position:
calc(42px - 4px) calc(-126px - 4px),
calc(126px - 4px) calc(100vh + 126px - 4px),
calc(-126px - 4px) calc(84px - 4px),
calc(100vw + 126px - 4px) calc(168px - 4px),
calc(294px - 4px) calc(-210px - 4px),
calc(378px - 4px) calc(100vh + 168px - 4px),
calc(-168px - 4px) calc(336px - 4px),
calc(100vw + 168px - 4px) calc(420px - 4px),
calc(546px - 4px) calc(-168px - 4px),
calc(714px - 4px) calc(100vh + 210px - 4px);
opacity: 0.2;
}
50% {
background-position:
calc(42px - 4px) calc(50vh - 4px),
calc(126px - 4px) calc(50vh - 4px),
calc(50vw - 4px) calc(84px - 4px),
calc(50vw - 4px) calc(168px - 4px),
calc(294px - 4px) calc(50vh - 4px),
calc(378px - 4px) calc(50vh - 4px),
calc(50vw - 4px) calc(336px - 4px),
calc(50vw - 4px) calc(420px - 4px),
calc(546px - 4px) calc(50vh - 4px),
calc(714px - 4px) calc(50vh - 4px);
opacity: 0.38;
}
100% {
background-position:
calc(42px - 4px) calc(100vh + 126px - 4px),
calc(126px - 4px) calc(-126px - 4px),
calc(100vw + 126px - 4px) calc(84px - 4px),
calc(-126px - 4px) calc(168px - 4px),
calc(294px - 4px) calc(100vh + 210px - 4px),
calc(378px - 4px) calc(-168px - 4px),
calc(100vw + 168px - 4px) calc(336px - 4px),
calc(-168px - 4px) calc(420px - 4px),
calc(546px - 4px) calc(100vh + 168px - 4px),
calc(714px - 4px) calc(-210px - 4px);
opacity: 0.16;
}
} }
[data-theme="light"] { [data-theme="light"] {
@@ -75,6 +118,9 @@
--overlay-scrim: rgba(255,255,255,0.72); --overlay-scrim: rgba(255,255,255,0.72);
--popup-shadow: 0 16px 42px rgba(0,0,0,0.18); --popup-shadow: 0 16px 42px rgba(0,0,0,0.18);
--avatar-inner-ring: #ffffff; --avatar-inner-ring: #ffffff;
--bg-grid-line: rgba(0, 0, 0, 0.06);
--bg-grid-glow: rgba(0, 0, 0, 0.05);
--bg-grid-streak: rgba(255, 255, 255, 1);
--table-scroll-thumb: rgba(0,0,0,0.1); --table-scroll-thumb: rgba(0,0,0,0.1);
--table-scroll-thumb-hover: rgba(0,0,0,0.2); --table-scroll-thumb-hover: rgba(0,0,0,0.2);
} }
@@ -90,8 +136,6 @@
} }
[data-theme="light"] input::placeholder, [data-theme="light"] input::placeholder,
[data-theme="light"] textarea::placeholder { color: #aaa; } [data-theme="light"] textarea::placeholder { color: #aaa; }
[data-theme="light"] th { background: #fafafa; }
[data-theme="light"] tr:hover td { background: #fafafa; }
[data-theme="light"] .assign-select { background: #fff; color: #1a1a1a; } [data-theme="light"] .assign-select { background: #fff; color: #1a1a1a; }
[data-theme="light"] .badge-not_started { background: #f1f5f9; color: #64748b; border-color: #e2e8f0; } [data-theme="light"] .badge-not_started { background: #f1f5f9; color: #64748b; border-color: #e2e8f0; }
[data-theme="light"] .badge-in_progress { background: #eff6ff; color: #2563eb; border-color: #bfdbfe; } [data-theme="light"] .badge-in_progress { background: #eff6ff; color: #2563eb; border-color: #bfdbfe; }
@@ -128,9 +172,63 @@ body {
color: var(--text-primary); color: var(--text-primary);
font-size: 14px; font-size: 14px;
line-height: 1.5; line-height: 1.5;
position: relative;
isolation: isolate;
} }
#root { all: unset; display: block; height: 100%; } body::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
background-image:
linear-gradient(to right, var(--bg-grid-line) 1px, transparent 1px),
linear-gradient(to bottom, var(--bg-grid-line) 1px, transparent 1px),
radial-gradient(circle at 50% 0%, var(--bg-grid-glow) 0%, transparent 62%);
background-size: 42px 42px, 42px 42px, 100% 100%;
background-position: 0 0, 0 0, center top;
mask-image: linear-gradient(180deg, rgba(0,0,0,1) 0%, rgba(0,0,0,0.92) 18%, rgba(0,0,0,0.58) 62%, rgba(0,0,0,0.2) 100%);
-webkit-mask-image: linear-gradient(180deg, rgba(0,0,0,1) 0%, rgba(0,0,0,0.92) 18%, rgba(0,0,0,0.58) 62%, rgba(0,0,0,0.2) 100%);
}
body::after {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
background-image:
radial-gradient(circle, var(--bg-grid-streak) 0 1.64px, rgba(255,255,255,1) 2.02px, transparent 4.15px),
radial-gradient(circle, color-mix(in srgb, var(--bg-grid-streak) 99%, transparent) 0 1.58px, rgba(255,255,255,0.98) 1.96px, transparent 4px),
radial-gradient(circle, color-mix(in srgb, var(--bg-grid-streak) 97%, transparent) 0 1.54px, rgba(255,255,255,0.94) 1.9px, transparent 3.88px),
radial-gradient(circle, color-mix(in srgb, var(--bg-grid-streak) 95%, transparent) 0 1.5px, rgba(255,255,255,0.9) 1.84px, transparent 3.74px),
radial-gradient(circle, color-mix(in srgb, var(--bg-grid-streak) 98%, transparent) 0 1.6px, rgba(255,255,255,0.98) 1.98px, transparent 4.06px),
radial-gradient(circle, color-mix(in srgb, var(--bg-grid-streak) 94%, transparent) 0 1.48px, rgba(255,255,255,0.88) 1.8px, transparent 3.66px),
radial-gradient(circle, color-mix(in srgb, var(--bg-grid-streak) 96%, transparent) 0 1.52px, rgba(255,255,255,0.92) 1.88px, transparent 3.8px),
radial-gradient(circle, color-mix(in srgb, var(--bg-grid-streak) 100%, transparent) 0 1.66px, rgba(255,255,255,1) 2.04px, transparent 4.18px),
radial-gradient(circle, color-mix(in srgb, var(--bg-grid-streak) 97%, transparent) 0 1.56px, rgba(255,255,255,0.95) 1.92px, transparent 3.92px),
radial-gradient(circle, color-mix(in srgb, var(--bg-grid-streak) 93%, transparent) 0 1.46px, rgba(255,255,255,0.86) 1.76px, transparent 3.56px);
background-size: 8px 8px, 8px 8px, 8px 8px, 8px 8px, 8px 8px, 8px 8px, 8px 8px, 8px 8px, 8px 8px, 8px 8px;
background-repeat: no-repeat;
filter: blur(0.22px);
mix-blend-mode: screen;
mask-image:
linear-gradient(to right, #000 0 1px, transparent 1px 42px),
linear-gradient(to bottom, #000 0 1px, transparent 1px 42px),
linear-gradient(180deg, rgba(0,0,0,1) 0%, rgba(0,0,0,0.94) 20%, rgba(0,0,0,0.62) 64%, rgba(0,0,0,0.22) 100%);
mask-size: 42px 42px, 42px 42px, 100% 100%;
mask-position: 0 0, 0 0, 0 0;
-webkit-mask-image:
linear-gradient(to right, #000 0 1px, transparent 1px 42px),
linear-gradient(to bottom, #000 0 1px, transparent 1px 42px),
linear-gradient(180deg, rgba(0,0,0,1) 0%, rgba(0,0,0,0.94) 20%, rgba(0,0,0,0.62) 64%, rgba(0,0,0,0.22) 100%);
-webkit-mask-size: 42px 42px, 42px 42px, 100% 100%;
-webkit-mask-position: 0 0, 0 0, 0 0;
animation: ambient-grid-flow 9.5s linear infinite;
}
#root { all: unset; display: block; height: 100%; position: relative; z-index: 1; }
/* Layout */ /* Layout */
.app-layout { display: flex; height: 100vh; overflow: hidden; } .app-layout { display: flex; height: 100vh; overflow: hidden; }
@@ -330,7 +428,6 @@ input.site-header-search[type="text"]:focus { border-color: var(--accent); }
.site-header-search-icon { position: absolute; left: 13px; top: 50%; transform: translateY(-50%); color: var(--text-muted); pointer-events: none; display: flex; } .site-header-search-icon { position: absolute; left: 13px; top: 50%; transform: translateY(-50%); color: var(--text-muted); pointer-events: none; display: flex; }
.site-header-search-icon svg { width: 14px; height: 14px; } .site-header-search-icon svg { width: 14px; height: 14px; }
.site-header-dropdown { position: absolute; top: calc(100% + 6px); right: 0; width: 340px; background: var(--sidebar-bg); border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.4); z-index: 300; overflow: hidden; } .site-header-dropdown { position: absolute; top: calc(100% + 6px); right: 0; width: 340px; background: var(--sidebar-bg); border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.4); z-index: 300; overflow: hidden; }
.site-header-dropdown { animation: ui-fade-up var(--motion-fast) var(--motion-ease); }
.site-header-dropdown-group { padding: 8px 12px 4px; font-size: 10px; font-weight: 500; letter-spacing: 0.8px; text-transform: uppercase; color: var(--text-muted); } .site-header-dropdown-group { padding: 8px 12px 4px; font-size: 10px; font-weight: 500; letter-spacing: 0.8px; text-transform: uppercase; color: var(--text-muted); }
.site-header-dropdown-item { display: flex; align-items: center; gap: 10px; padding: 9px 12px; cursor: pointer; font-size: 13px; color: var(--text-primary); border: 1px solid transparent; } .site-header-dropdown-item { display: flex; align-items: center; gap: 10px; padding: 9px 12px; cursor: pointer; font-size: 13px; color: var(--text-primary); border: 1px solid transparent; }
.site-header-dropdown-item:hover { color: #fff; } .site-header-dropdown-item:hover { color: #fff; }
@@ -343,7 +440,6 @@ input.site-header-search[type="text"]:focus { border-color: var(--accent); }
.site-header-avatar-btn:hover { opacity: 0.85; } .site-header-avatar-btn:hover { opacity: 0.85; }
[data-theme="light"] .site-header-avatar-btn { border-color: #fff; } [data-theme="light"] .site-header-avatar-btn { border-color: #fff; }
.site-header-avatar-menu { position: absolute; top: calc(100% + 8px); right: 0; background: var(--sidebar-bg); border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.4); z-index: 300; min-width: 160px; overflow: hidden; } .site-header-avatar-menu { position: absolute; top: calc(100% + 8px); right: 0; background: var(--sidebar-bg); border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.4); z-index: 300; min-width: 160px; overflow: hidden; }
.site-header-avatar-menu { animation: ui-fade-up var(--motion-fast) var(--motion-ease); }
.site-header-avatar-item { padding: 10px 16px; cursor: pointer; font-size: 13px; color: var(--text-primary); display: block; width: 100%; text-align: left; background: none; border: 1px solid transparent; font-family: inherit; } .site-header-avatar-item { padding: 10px 16px; cursor: pointer; font-size: 13px; color: var(--text-primary); display: block; width: 100%; text-align: left; background: none; border: 1px solid transparent; font-family: inherit; }
.site-header-avatar-item:hover { color: #fff; } .site-header-avatar-item:hover { color: #fff; }
.site-header-avatar-divider { height: 1px; background: var(--border); margin: 4px 0; } .site-header-avatar-divider { height: 1px; background: var(--border); margin: 4px 0; }
@@ -693,41 +789,14 @@ input.site-header-search[type="text"]:focus { border-color: var(--accent); }
font-size: 13px; font-size: 13px;
} }
/* File Sharing */ .shared-progress-bar {
.file-browser {
width: 100%;
max-width: none;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 4px;
overflow: hidden;
position: relative;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.file-browser-progress {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
z-index: 10;
background: transparent;
border-radius: 4px 4px 0 0;
overflow: hidden;
}
.file-browser-progress-bar {
height: 100%; height: 100%;
background: var(--accent); background: var(--accent);
transition: width 0.2s ease; transition: width 0.2s ease;
border-radius: 0; border-radius: 0;
} }
.file-browser-progress-bar.indeterminate { .shared-progress-bar.indeterminate {
width: 40% !important; width: 40% !important;
animation: progress-slide 1.4s ease-in-out infinite; animation: progress-slide 1.4s ease-in-out infinite;
} }
@@ -737,168 +806,6 @@ input.site-header-search[type="text"]:focus { border-color: var(--accent); }
100% { transform: translateX(350%); } 100% { transform: translateX(350%); }
} }
.file-browser-dragging {
border-color: var(--accent);
}
.file-browser-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 14px;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
background: var(--card-bg);
}
.file-browser-breadcrumbs,
.file-browser-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.file-breadcrumb {
border: 1px solid var(--border);
background: var(--card-bg-2);
color: var(--text-primary);
border-radius: 4px;
padding: 7px 10px;
font-family: inherit;
font-size: 12px;
font-weight: 400;
cursor: pointer;
}
.file-breadcrumb::before {
content: '/';
color: var(--text-muted);
margin-right: 6px;
}
.file-breadcrumb:hover {
border-color: var(--interactive-hover-border);
}
.file-browser-create {
display: grid;
grid-template-columns: minmax(180px, 340px) auto;
gap: 8px;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
background: var(--card-bg-2);
}
.file-upload-input {
display: none;
}
.file-list {
flex: 1;
min-height: 0;
position: relative;
overflow-x: auto;
overflow-y: auto;
}
.file-row {
display: grid;
grid-template-columns: 34px minmax(180px, 2fr) minmax(90px, 120px) minmax(110px, 140px) minmax(220px, 1fr);
gap: 10px;
align-items: center;
width: 100%;
min-width: 700px;
min-height: 52px;
padding: 0 16px;
border: 0;
border-bottom: 1px solid var(--border);
background: transparent;
color: var(--text-primary);
font-family: inherit;
text-align: left;
}
.file-row:last-child {
border-bottom: 0;
}
.file-row:not(.file-row-head):hover,
.file-row-button:hover {
background: var(--interactive-row-hover);
}
.file-row-head {
min-height: 38px;
color: var(--text-muted);
font-size: 10px;
font-weight: 400;
text-transform: uppercase;
letter-spacing: 0.6px;
background: var(--card-bg);
}
.file-row-button {
cursor: pointer;
}
.file-icon {
width: 28px;
height: 28px;
border: 1px solid var(--border);
border-radius: 4px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--accent);
background: var(--card-bg-2);
}
.file-name,
.file-name-button {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-primary);
font-size: 13px;
font-weight: 400;
}
.file-name-button {
border: 0;
background: transparent;
font-family: inherit;
text-align: left;
cursor: pointer;
}
.file-name-button:hover {
color: var(--accent);
}
.file-meta {
color: var(--text-secondary);
font-size: 12px;
text-align: right;
}
.file-row-actions {
display: flex;
gap: 6px;
justify-content: flex-end;
}
.file-action-btn {
width: 32px;
height: 32px;
padding: 0;
justify-content: center;
font-size: 14px;
}
.btn-icon { .btn-icon {
background: none; background: none;
border: none; border: none;
@@ -932,75 +839,6 @@ input.site-header-search[type="text"]:focus { border-color: var(--accent); }
background: var(--interactive-row-hover); background: var(--interactive-row-hover);
} }
.file-drop-overlay {
position: absolute;
inset: 0;
z-index: 8;
display: flex;
align-items: center;
justify-content: center;
background: rgba(17, 17, 17, 0.72);
border: 2px dashed var(--accent);
pointer-events: none;
}
.file-drop-panel {
min-width: 280px;
max-width: min(420px, calc(100% - 40px));
border: 1px solid rgba(245, 165, 35, 0.45);
border-radius: 4px;
background: var(--card-bg);
padding: 28px;
text-align: center;
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.28);
}
.file-drop-icon {
width: 46px;
height: 46px;
border-radius: 4px;
background: var(--accent);
color: #111;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: 400;
margin-bottom: 14px;
}
.file-drop-title {
font-size: 17px;
font-weight: 400;
color: var(--text-primary);
}
.file-drop-subtitle {
margin-top: 5px;
font-size: 13px;
color: var(--text-secondary);
}
@media (max-width: 760px) {
.file-browser-toolbar {
align-items: stretch;
flex-direction: column;
}
.file-browser-actions {
justify-content: flex-start;
}
.file-browser-create {
grid-template-columns: 1fr;
}
.file-row {
grid-template-columns: 34px minmax(150px, 2fr) 90px 110px minmax(170px, 1fr);
min-width: 700px;
}
}
/* Buttons */ /* Buttons */
.btn { .btn {
display: inline-flex; align-items: center; gap: 6px; display: inline-flex; align-items: center; gap: 6px;
@@ -1133,11 +971,10 @@ textarea {
.table-wrapper::-webkit-scrollbar-thumb:hover { .table-wrapper::-webkit-scrollbar-thumb:hover {
background: var(--table-scroll-thumb-hover); background: var(--table-scroll-thumb-hover);
} }
table { width: 100%; border-collapse: collapse; } table { width: 100%; border-collapse: collapse; table-layout: fixed; }
th { text-align: left; padding: 12px 16px; font-size: 10px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.8px; color: var(--text-muted); background: rgba(255,255,255,0.07); border-bottom: 1px solid var(--border); } th { text-align: left; padding: 0 16px 12px; font-size: 10px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.6px; color: var(--text-muted); background: transparent; border: none; vertical-align: top; }
td { padding: 14px 16px; border-bottom: 1px solid var(--border); font-size: 13px; color: var(--text-primary); } td { padding: 5px 16px; border: none; font-size: 13px; color: var(--text-primary); background: transparent; }
tr:hover td { background: rgba(255,255,255,0.02); }
.table-no-row-hover tr:hover td { background: transparent !important; } .table-no-row-hover tr:hover td { background: transparent !important; }
[data-theme="light"] .table-no-row-hover tr:hover td { background: transparent !important; } [data-theme="light"] .table-no-row-hover tr:hover td { background: transparent !important; }
.table-no-row-hover tbody tr:hover .table-link:not(:hover) { color: inherit !important; } .table-no-row-hover tbody tr:hover .table-link:not(:hover) { color: inherit !important; }
@@ -1149,6 +986,17 @@ tr:hover td { background: rgba(255,255,255,0.02); }
cursor: pointer; cursor: pointer;
display: inline-block; display: inline-block;
} }
button.table-link {
background: none;
border: none;
padding: 0;
margin: 0;
font: inherit;
text-align: left;
appearance: none;
-webkit-appearance: none;
box-shadow: none;
}
.table-link:hover, .table-link:hover,
.table-link:focus-visible { .table-link:focus-visible {
color: var(--accent) !important; color: var(--accent) !important;
@@ -1269,8 +1117,8 @@ textarea { resize: vertical; height: auto; min-height: 100px; padding: 8px 14px;
select option { background: #222; color: #fff; } select option { background: #222; color: #fff; }
/* Auth */ /* Auth */
.auth-page { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #0d0d0d; padding: 20px; } .auth-page { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: transparent; padding: 20px; position: relative; z-index: 1; }
.auth-card { background: #1a1a1a; border-radius: 4px; border: 1px solid var(--border); padding: 40px; width: 100%; max-width: 440px; } .auth-card { background: var(--card-bg); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border-radius: 4px; border: 1px solid var(--border); padding: 40px; width: 100%; max-width: 440px; box-shadow: var(--popup-shadow); }
.auth-logo { text-align: center; margin-bottom: 32px; } .auth-logo { text-align: center; margin-bottom: 32px; }
.auth-logo h1 { font-size: 22px; font-weight: 400; color: #ffffff; letter-spacing: -0.5px; } .auth-logo h1 { font-size: 22px; font-weight: 400; color: #ffffff; letter-spacing: -0.5px; }
.auth-logo p { font-size: 13px; color: #888; margin-top: 6px; } .auth-logo p { font-size: 13px; color: #888; margin-top: 6px; }
@@ -1280,24 +1128,24 @@ select option { background: #222; color: #fff; }
.auth-card input[type="password"], .auth-card input[type="password"],
.auth-card select, .auth-card select,
.auth-card textarea { .auth-card textarea {
background: #222; background: var(--card-bg-2);
border-color: #333; border-color: var(--border);
color: #fff; color: var(--text-primary);
} }
.auth-card input::placeholder, .auth-card input::placeholder,
.auth-card textarea::placeholder { color: #555; } .auth-card textarea::placeholder { color: var(--text-muted); }
.auth-card input:focus, .auth-card select:focus, .auth-card textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(245,165,35,0.1); } .auth-card input:focus, .auth-card select:focus, .auth-card textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(245,165,35,0.1); }
.auth-card .btn-outline { border-color: #333; color: #888; } .auth-card .btn-outline { border-color: var(--border); color: var(--text-secondary); }
.auth-card .btn-outline:hover { background: #222; color: #fff; } .auth-card .btn-outline:hover { background: var(--card-bg-2); color: var(--text-primary); }
.auth-divider { display: flex; align-items: center; gap: 12px; margin: 20px 0; color: #444; font-size: 12px; } .auth-divider { display: flex; align-items: center; gap: 12px; margin: 20px 0; color: var(--text-muted); font-size: 12px; }
.auth-divider::before, .auth-divider::after { content: ''; flex: 1; height: 1px; background: var(--border); } .auth-divider::before, .auth-divider::after { content: ''; flex: 1; height: 1px; background: var(--border); }
.quick-login { margin-top: 20px; padding-top: 20px; border-top: 1px solid var(--border); } .quick-login { margin-top: 20px; padding-top: 20px; border-top: 1px solid var(--border); }
.quick-login-title { font-size: 10px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.5px; color: #555; margin-bottom: 10px; } .quick-login-title { font-size: 10px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); margin-bottom: 10px; }
.quick-login-list { display: flex; flex-direction: column; gap: 6px; } .quick-login-list { display: flex; flex-direction: column; gap: 6px; }
.quick-login-btn { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border-radius: 4px; border: 1px solid #2a2a2a; background: #222; cursor: pointer; transition: background 0.15s; text-align: left; width: 100%; } .quick-login-btn { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border-radius: 4px; border: 1px solid var(--border); background: var(--card-bg-2); cursor: pointer; transition: background 0.15s; text-align: left; width: 100%; }
.quick-login-btn:hover { background: #2a2a2a; } .quick-login-btn:hover { background: color-mix(in srgb, var(--card-bg-2) 72%, var(--accent) 28%); }
.quick-login-name { font-size: 13px; font-weight: 400; color: #fff; } .quick-login-name { font-size: 13px; font-weight: 400; color: var(--text-primary); }
.quick-login-email { font-size: 11px; color: #666; } .quick-login-email { font-size: 11px; color: var(--text-muted); }
/* Misc */ /* Misc */
.back-link { display: inline-flex; align-items: center; gap: 6px; color: var(--text-muted); text-decoration: none; font-size: 12px; font-weight: 500; margin-bottom: 20px; cursor: pointer; border: none; background: none; text-transform: uppercase; letter-spacing: 0.5px; font-family: inherit; } .back-link { display: inline-flex; align-items: center; gap: 6px; color: var(--text-muted); text-decoration: none; font-size: 12px; font-weight: 500; margin-bottom: 20px; cursor: pointer; border: none; background: none; text-transform: uppercase; letter-spacing: 0.5px; font-family: inherit; }
@@ -1325,6 +1173,104 @@ select option { background: #222; color: #fff; }
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px; } .detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px; }
.detail-item label { font-size: 10px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.6px; color: var(--text-muted); margin-bottom: 4px; } .detail-item label { font-size: 10px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.6px; color: var(--text-muted); margin-bottom: 4px; }
.detail-item p { font-size: 14px; color: var(--text-primary); font-weight: 500; } .detail-item p { font-size: 14px; color: var(--text-primary); font-weight: 500; }
.invoice-detail-shell {
display: flex;
flex-direction: column;
}
.invoice-detail-stack {
display: flex;
flex-direction: column;
gap: 24px;
}
.invoice-detail-summary-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 24px;
}
.invoice-detail-card {
margin: 0;
padding: 18px 21px;
border-radius: 8px;
}
.invoice-detail-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.invoice-detail-card-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
flex-shrink: 0;
}
.invoice-detail-section-title {
margin-bottom: 16px;
font-size: 11px;
font-weight: 500;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.8px;
}
.invoice-detail-meta-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 18px 24px;
margin-bottom: 0;
}
.invoice-detail-meta-item label {
display: block;
margin-bottom: 4px;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text-secondary);
}
.invoice-detail-meta-item p {
font-size: 13px;
line-height: 1.45;
color: var(--text-primary);
font-weight: 400;
}
.invoice-detail-table tbody td {
vertical-align: top;
}
.invoice-detail-cell {
padding: 5px 0;
font-size: 13px;
}
.invoice-detail-total-row {
display: flex;
justify-content: flex-end;
padding-top: 16px;
margin-top: 12px;
border-top: 1px solid var(--border);
flex-shrink: 0;
}
.invoice-detail-total-label {
margin-bottom: 4px;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text-secondary);
}
.invoice-detail-total-value {
font-size: 24px;
line-height: 1.1;
font-weight: 400;
color: var(--accent);
}
.invoice-detail-notes {
font-size: 14px;
color: var(--text-secondary);
line-height: 1.6;
white-space: pre-wrap;
}
.notification { padding: 12px 16px; border-radius: 4px; font-size: 13px; font-weight: 500; margin-bottom: 20px; } .notification { padding: 12px 16px; border-radius: 4px; font-size: 13px; font-weight: 500; margin-bottom: 20px; }
.notification-success { background: rgba(34,197,94,0.1); color: #4ade80; border: 1px solid rgba(34,197,94,0.2); } .notification-success { background: rgba(34,197,94,0.1); color: #4ade80; border: 1px solid rgba(34,197,94,0.2); }
.notification-info { background: rgba(37,99,235,0.1); color: #60a5fa; border: 1px solid rgba(37,99,235,0.2); } .notification-info { background: rgba(37,99,235,0.1); color: #60a5fa; border: 1px solid rgba(37,99,235,0.2); }
@@ -1475,6 +1421,20 @@ select option { background: #222; color: #fff; }
color: var(--text-muted); color: var(--text-muted);
} }
.action-buttons { display: flex; gap: 8px; flex-wrap: wrap; } .action-buttons { display: flex; gap: 8px; flex-wrap: wrap; }
.modal-action-row {
display: flex;
gap: 8px;
justify-content: flex-end;
flex-wrap: nowrap;
}
.modal-action-row > .btn,
.modal-action-row > .btn-icon,
.modal-action-row > a.btn {
width: 132px;
min-width: 132px;
flex: 0 0 132px;
justify-content: center;
}
.assign-select { height: var(--h-control); padding: 0 10px; border-radius: 4px; border: 1px solid var(--border); font-size: 13px; color: var(--text-primary); background: var(--card-bg-2); cursor: pointer; font-family: inherit; line-height: 1; } .assign-select { height: var(--h-control); padding: 0 10px; border-radius: 4px; border: 1px solid var(--border); font-size: 13px; color: var(--text-primary); background: var(--card-bg-2); cursor: pointer; font-family: inherit; line-height: 1; }
.assign-select option { background: #222; } .assign-select option { background: #222; }
.flex { display: flex; } .flex { display: flex; }
@@ -1649,6 +1609,8 @@ select option { background: #222; color: #fff; }
/* Stack grids on mobile */ /* Stack grids on mobile */
.grid-2 { grid-template-columns: 1fr; } .grid-2 { grid-template-columns: 1fr; }
.invoice-detail-summary-grid { grid-template-columns: 1fr; }
.invoice-detail-meta-grid { grid-template-columns: 1fr; gap: 16px; }
.stats-grid { grid-template-columns: 1fr 1fr; } .stats-grid { grid-template-columns: 1fr 1fr; }
.dash-stat-grid { grid-template-columns: 1fr 1fr; } .dash-stat-grid { grid-template-columns: 1fr 1fr; }
.detail-grid { grid-template-columns: 1fr 1fr; } .detail-grid { grid-template-columns: 1fr 1fr; }
@@ -1702,6 +1664,8 @@ select option { background: #222; color: #fff; }
/* Shared section tabs (Tasks / Projects / Finances / Detail tabs) */ /* Shared section tabs (Tasks / Projects / Finances / Detail tabs) */
.section-tab-btn { .section-tab-btn {
font-family: 'Fourge', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-family: 'Fourge', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
display: inline-flex;
align-items: center;
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
letter-spacing: 0.2px; letter-spacing: 0.2px;
@@ -1713,22 +1677,26 @@ select option { background: #222; color: #fff; }
cursor: pointer; cursor: pointer;
background: transparent; background: transparent;
color: var(--text-secondary); color: var(--text-secondary);
box-shadow: inset 0 -2px 0 transparent;
transition: all 160ms; transition: all 160ms;
} }
.section-tab-btn:hover, .section-tab-btn:hover,
.section-tab-btn:focus-visible { .section-tab-btn:focus-visible {
color: var(--accent); color: var(--accent);
border-bottom-color: var(--accent); border-bottom-color: var(--accent);
box-shadow: inset 0 -2px 0 var(--accent);
outline: none; outline: none;
} }
button.section-tab-btn:hover, button.section-tab-btn:hover,
button.section-tab-btn:focus-visible { button.section-tab-btn:focus-visible {
color: var(--accent) !important; color: var(--accent) !important;
border-bottom-color: var(--accent) !important; border-bottom-color: var(--accent) !important;
box-shadow: inset 0 -2px 0 var(--accent) !important;
} }
.section-tab-btn.is-active { .section-tab-btn.is-active {
color: var(--text-primary); color: var(--text-primary);
border-bottom-color: var(--accent); border-bottom-color: var(--accent);
box-shadow: inset 0 -2px 0 var(--accent);
} }
/* Rebuilt hover system (single source of truth) */ /* Rebuilt hover system (single source of truth) */
+6 -7
View File
@@ -1,4 +1,9 @@
import jsPDF from 'jspdf'; import jsPDF from 'jspdf';
import { formatLongDate } from './dates';
function formatDate(dateStr) {
return formatLongDate(dateStr ? `${dateStr}T12:00:00` : '', '-');
}
// Letter landscape: 792 x 612 pt // Letter landscape: 792 x 612 pt
const W = 792; const W = 792;
@@ -8,12 +13,6 @@ const ACCENT = [245, 165, 35];
const DARK = [18, 18, 18]; const DARK = [18, 18, 18];
const HEADER_H = 64; const HEADER_H = 64;
function formatDate(dateStr) {
if (!dateStr) return '-';
const d = new Date(dateStr + 'T00:00:00');
return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
}
function formatCoverAddress(address) { function formatCoverAddress(address) {
const parts = String(address || '') const parts = String(address || '')
.split(',') .split(',')
@@ -384,7 +383,7 @@ export async function generateBrandBookEditorPDF(data) {
let pageNum = 0; let pageNum = 0;
const rev = String(revision || '01').padStart(2, '0'); const rev = String(revision || '01').padStart(2, '0');
const displayDate = bookDate const displayDate = bookDate
? new Date(bookDate + 'T12:00:00').toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) ? formatLongDate(`${bookDate}T12:00:00`, '-')
: ''; : '';
// ─── COVER PAGE ────────────────────────────────────────────────────────────── // ─── COVER PAGE ──────────────────────────────────────────────────────────────
+40
View File
@@ -8,6 +8,46 @@ export function formatDateEST(value) {
}); });
} }
export function formatShortDate(value, fallback = '—') {
if (!value) return fallback;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return fallback;
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
export function formatLongDate(value, fallback = '—') {
if (!value) return fallback;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return fallback;
return date.toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
});
}
export function formatShortDateTime(value, fallback = '—') {
if (!value) return fallback;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return fallback;
return `${formatShortDate(date, fallback)} ${date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true,
})}`;
}
export function formatDateAtNoon(value, fallback = '—') {
if (!value) return fallback;
const date = new Date(`${value}T12:00:00`);
if (Number.isNaN(date.getTime())) return fallback;
return formatLongDate(date, fallback);
}
export function parseDateOnly(value) { export function parseDateOnly(value) {
if (!value) return null; if (!value) return null;
const [year, month, day] = String(value).split('-').map(Number); const [year, month, day] = String(value).split('-').map(Number);
-114
View File
@@ -1,114 +0,0 @@
import { supabase } from './supabase';
const FBQ_FN = `${import.meta.env.VITE_SUPABASE_URL}/functions/v1/fbq-proxy`;
async function fbq(op, path, extra = {}) {
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) return;
const headers = {
Authorization: `Bearer ${session.access_token}`,
'X-Operation': op,
'X-Path': path,
...extra.headers,
};
await fetch(FBQ_FN, { method: 'POST', headers, body: extra.body ?? undefined }).catch(() => {});
}
export function safeName(v) {
return String(v || '').trim()
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
.replace(/\s+/g, ' ')
.replace(/^-+|-+$/g, '');
}
export async function createProjectFolder(companyName, projectName) {
if (!companyName || !projectName) return;
const co = safeName(companyName);
const proj = safeName(projectName);
await fbq('mkdir', `/Clients/${co}/`);
await fbq('mkdir', `/Clients/${co}/Projects/`);
await fbq('mkdir', `/Clients/${co}/Projects/${proj}/`);
await fbq('mkdir', `/Clients/${co}/Projects/${proj}/00 Project Files/`);
}
export async function createTeamMemberFolder(name) {
if (!name) return;
await fbq('mkdir', '/Team/');
await fbq('mkdir', `/Team/${safeName(name)}/`);
}
export async function createTaskFolder(companyName, projectName, taskTitle) {
if (!companyName || !projectName || !taskTitle) return;
const co = safeName(companyName);
const proj = safeName(projectName);
const task = safeName(taskTitle);
const base = `/Clients/${co}/Projects/${proj}/${task}`;
const mkdirs = [
`/Clients/${co}/`,
`/Clients/${co}/Projects/`,
`/Clients/${co}/Projects/${proj}/`,
`/Clients/${co}/Projects/${proj}/00 Project Files/`,
`${base}/`,
`${base}/Request Info/`,
`${base}/Old Books/`,
];
for (const dir of mkdirs) {
await fbq('mkdir', dir);
}
}
export async function createClientFolder(companyName) {
if (!companyName) return;
await fbq('mkdir', '/Clients/');
await fbq('mkdir', `/Clients/${companyName}/`);
}
export async function backfillClientFolders() {
const { data } = await supabase.from('companies').select('name');
if (!data?.length) return;
await fbq('mkdir', '/Clients/');
for (const company of data) {
if (company.name) await fbq('mkdir', `/Clients/${company.name}/`);
}
}
export async function uploadFilesToRequestInfo(files, companyName, projectName, taskTitle, versionNumber = 0) {
if (!files?.length || !companyName || !projectName || !taskTitle) return;
const co = safeName(companyName);
const proj = safeName(projectName);
const task = safeName(taskTitle);
const rev = `R${String(versionNumber).padStart(2, '0')}`;
const base = `/Clients/${co}/Projects/${proj}/${task}/Request Info/${rev}`;
const mkdirs = [
`/Clients/${co}/`,
`/Clients/${co}/Projects/`,
`/Clients/${co}/Projects/${proj}/`,
`/Clients/${co}/Projects/${proj}/${task}/`,
`/Clients/${co}/Projects/${proj}/${task}/Request Info/`,
`/Clients/${co}/Projects/${proj}/${task}/Working Files/`,
`/Clients/${co}/Projects/${proj}/${task}/Old Books/`,
`${base}/`,
];
for (const dir of mkdirs) {
await fbq('mkdir', dir);
}
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) return;
for (const file of files) {
const filePath = `${base}/${file.name}`;
const fd = new FormData(); fd.append('file', file);
await fetch(FBQ_FN, {
method: 'POST',
headers: {
Authorization: `Bearer ${session.access_token}`,
'X-Operation': 'upload',
'X-Path': filePath,
},
body: fd,
}).catch(() => {});
}
}
+62
View File
@@ -0,0 +1,62 @@
import { supabase } from './supabase';
async function callFolderSync(action, body) {
const { data: { session } } = await supabase.auth.getSession();
const accessToken = session?.access_token;
if (!accessToken) return { ok: false, skipped: true, warning: 'No active session.' };
const response = await fetch(`/api/filebrowser?action=${encodeURIComponent(action)}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload?.error || 'Folder sync failed.');
}
return payload;
}
export async function ensureCompanyFolder({ companyId }) {
if (!companyId) return { ok: false, skipped: true };
return callFolderSync('ensure-company-folder', { companyId });
}
export async function renameCompanyFolder({ companyId, oldName, newName }) {
if (!companyId || !oldName || !newName) return { ok: false, skipped: true };
return callFolderSync('rename-company-folder', { companyId, oldName, newName });
}
export async function ensureProjectFolder({ projectId }) {
if (!projectId) return { ok: false, skipped: true };
return callFolderSync('ensure-project-folder', { projectId });
}
export async function renameProjectFolder({ projectId, oldName, newName }) {
if (!projectId || !oldName || !newName) return { ok: false, skipped: true };
return callFolderSync('rename-project-folder', { projectId, oldName, newName });
}
export async function ensureSubcontractorFolder({ profileId }) {
if (!profileId) return { ok: false, skipped: true };
return callFolderSync('ensure-profile-folder', { profileId });
}
export async function renameSubcontractorFolder({ profileId, oldName, newName }) {
if (!profileId || !oldName || !newName) return { ok: false, skipped: true };
return callFolderSync('rename-profile-folder', { profileId, oldName, newName });
}
export async function ensureRequestFolder({ projectId, taskTitle }) {
if (!projectId || !taskTitle) return { ok: false, skipped: true };
return callFolderSync('ensure-request-folder', { projectId, taskTitle });
}
export async function uploadRequestFileToSurvey({ projectId, taskTitle, storagePath, fileName, bucket = 'submissions' }) {
if (!projectId || !taskTitle || !storagePath || !fileName) return { ok: false, skipped: true };
return callFolderSync('upload-request-file', { projectId, taskTitle, storagePath, fileName, bucket });
}
+18
View File
@@ -0,0 +1,18 @@
export const CLIENT_APPROVED_OR_BETTER = new Set(['client_approved', 'invoiced', 'paid']);
export function isCompletedVersionEligible(task, versionNumber) {
const taskVersion = Number(task?.current_version || 0);
const version = Number(versionNumber || 0);
if (version < taskVersion) return true;
return version === taskVersion && CLIENT_APPROVED_OR_BETTER.has(task?.status);
}
export function isInitialVersionEligible(task) {
if (!task || task.invoiced) return false;
return isCompletedVersionEligible(task, 0);
}
export function getRevisionChargeQuantity(versionNumber) {
const version = Number(versionNumber || 0);
return version >= 2 ? 1 : 0;
}
+45 -16
View File
@@ -1,9 +1,18 @@
import { supabase } from './supabase'; import { supabase } from './supabase';
import { ensureProjectFolder } from './folderSync';
function normalizeProjectName(name = '') { function normalizeProjectName(name = '') {
return String(name).trim().toLowerCase(); return String(name).trim().toLowerCase();
} }
function serializeSubmissionSigns(normalizedSigns = []) {
return normalizedSigns.map((s, i) => ({
sign_number: i + 1,
sign_name: s.signName,
sign_family: s.signFamily || null,
}));
}
export async function findOrCreateProject(companyId, projectName, knownProjects = []) { export async function findOrCreateProject(companyId, projectName, knownProjects = []) {
const normalized = normalizeProjectName(projectName); const normalized = normalizeProjectName(projectName);
if (!companyId || !normalized) throw new Error('Project company and name are required.'); if (!companyId || !normalized) throw new Error('Project company and name are required.');
@@ -31,7 +40,14 @@ export async function findOrCreateProject(companyId, projectName, knownProjects
.select('id, name, company_id, status') .select('id, name, company_id, status')
.single(); .single();
if (!insertError && newProject) return newProject; if (!insertError && newProject) {
try {
await ensureProjectFolder({ projectId: newProject.id });
} catch (folderError) {
console.error('Project folder sync failed:', folderError);
}
return newProject;
}
if (insertError?.code === '23505') { if (insertError?.code === '23505') {
const { data: retryProjects, error: retryError } = await supabase const { data: retryProjects, error: retryError } = await supabase
@@ -88,6 +104,20 @@ export async function createInitialSubmissionForRequest({
submittedBy, submittedBy,
submittedByName, submittedByName,
}) { }) {
const normalizedSignCount = Number.isFinite(Number(signCount)) ? Number(signCount) : null;
const normalizedSigns = Array.isArray(signs)
? signs
.map((sign = {}) => ({
signName: String(sign.signName || '').trim(),
signFamily: String(sign.signFamily || '').trim(),
}))
.filter(sign => sign.signName || sign.signFamily)
: [];
if ((normalizedSignCount || 0) > 0 && normalizedSigns.length !== normalizedSignCount) {
throw new Error('Please fill in the sign information for each sign before submitting.');
}
const { data: submission, error } = await supabase const { data: submission, error } = await supabase
.from('submissions') .from('submissions')
.insert({ .insert({
@@ -98,7 +128,8 @@ export async function createInitialSubmissionForRequest({
is_hot: isHot, is_hot: isHot,
service_type: serviceType, service_type: serviceType,
sign_family: signFamily || null, sign_family: signFamily || null,
sign_count: signCount || null, sign_count: normalizedSignCount || null,
signs: serializeSubmissionSigns(normalizedSigns),
deadline: deadline || null, deadline: deadline || null,
description, description,
submitted_by: submittedBy, submitted_by: submittedBy,
@@ -107,19 +138,7 @@ export async function createInitialSubmissionForRequest({
.select() .select()
.single(); .single();
if (!error && submission) { if (!error && submission) return { submission, duplicate: false };
if (signs?.length > 0) {
await supabase.from('submission_signs').insert(
signs.map((s, i) => ({
submission_id: submission.id,
sign_number: i + 1,
sign_name: s.signName,
sign_family: s.signFamily || null,
}))
);
}
return { submission, duplicate: false };
}
if (error?.code === '23505' && requestKey) { if (error?.code === '23505' && requestKey) {
const { data: existingSubmission, error: existingError } = await supabase const { data: existingSubmission, error: existingError } = await supabase
@@ -128,7 +147,17 @@ export async function createInitialSubmissionForRequest({
.eq('request_key', requestKey) .eq('request_key', requestKey)
.single(); .single();
if (existingError) throw existingError; if (existingError) throw existingError;
if (existingSubmission) return { submission: existingSubmission, duplicate: true }; if (existingSubmission) {
const existingSigns = Array.isArray(existingSubmission.signs) ? existingSubmission.signs : [];
if (normalizedSigns.length > 0 && existingSigns.length === 0) {
const { error: repairError } = await supabase
.from('submissions')
.update({ signs: serializeSubmissionSigns(normalizedSigns) })
.eq('id', existingSubmission.id);
if (repairError) throw repairError;
}
return { submission: existingSubmission, duplicate: true };
}
} }
throw error || new Error('Failed to create submission.'); throw error || new Error('Failed to create submission.');
+16
View File
@@ -0,0 +1,16 @@
export function mergeSubmissionDisplayNames(submissions = [], profiles = []) {
const nameById = new Map(
(profiles || [])
.filter((profile) => profile?.id)
.map((profile) => [profile.id, profile.name || ''])
);
return (submissions || []).map((submission) => ({
...submission,
display_submitted_by_name: nameById.get(submission?.submitted_by) || submission?.submitted_by_name || '',
}));
}
export function getSubmissionDisplayName(submission) {
return submission?.display_submitted_by_name || submission?.submitted_by_name || '';
}
+2 -3
View File
@@ -1,4 +1,5 @@
import jsPDF from 'jspdf'; import jsPDF from 'jspdf';
import { formatDateAtNoon } from './dates';
const W = 792; const W = 792;
const H = 612; const H = 612;
@@ -8,9 +9,7 @@ const DARK = [18, 18, 18];
const HEADER_H = 64; const HEADER_H = 64;
function formatDate(dateStr) { function formatDate(dateStr) {
if (!dateStr) return '-'; return formatDateAtNoon(dateStr, '-');
const d = new Date(`${dateStr}T12:00:00`);
return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
} }
async function blobToDataUrl(blob) { async function blobToDataUrl(blob) {
+42
View File
@@ -0,0 +1,42 @@
import { sendEmail } from './email';
export async function sendTaskStatusUpdate({
taskId,
taskTitle,
projectId,
projectName = '',
companyId,
companyName = '',
assignedProfileId,
subject,
headline,
statusLabel,
message,
buttonLabel = 'View Task',
includeTeam = false,
includeAssigned = false,
includeClient = false,
includeProjectMembers = false,
recipientStrategy = '',
}) {
if (!taskId || !taskTitle || !statusLabel || !message) return;
return sendEmail('task_status_update', [], {
taskId,
taskTitle,
projectId,
projectName,
companyId,
companyName,
assignedProfileId,
subject,
headline,
statusLabel,
message,
buttonLabel,
includeTeam,
includeAssigned,
includeClient,
includeProjectMembers,
recipientStrategy,
});
}
+70
View File
@@ -0,0 +1,70 @@
import { popupSurfaceStyle } from './popupStyles';
export const TASK_TABLE_TH_STYLE = {
fontSize: 10,
fontWeight: 500,
textTransform: 'uppercase',
letterSpacing: 0.6,
color: 'var(--text-muted)',
textAlign: 'left',
padding: '0 0 12px 5px',
border: 'none',
background: 'transparent',
verticalAlign: 'top',
};
export const TASK_TABLE_TD_BASE = {
padding: '5px',
border: 'none',
background: 'transparent',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
};
export const TASK_MODAL_TITLE_STYLE = {
fontSize: 11,
fontWeight: 500,
textTransform: 'uppercase',
letterSpacing: 0.8,
color: 'var(--text-secondary)',
};
export const TASK_MODAL_LABEL_STYLE = {
fontSize: 11,
fontWeight: 500,
color: 'var(--text-secondary)',
textTransform: 'uppercase',
letterSpacing: 0.8,
marginBottom: 6,
};
export const TASK_MODAL_INPUT_STYLE = {
fontSize: 13,
padding: '0 5px',
textAlign: 'left',
lineHeight: 1,
};
export const TASK_MODAL_BUTTON_STYLE = {
lineHeight: 1,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
};
export const TASK_MODAL_SHELL_STYLE = {
...popupSurfaceStyle,
background: 'var(--card-bg)',
width: 'min(720px, 96vw)',
maxHeight: 'calc(100vh - 48px)',
overflowY: 'auto',
};
export const PROJECT_MODAL_SHELL_STYLE = {
...popupSurfaceStyle,
background: 'var(--card-bg)',
width: 'min(434px, 100%)',
maxHeight: 'calc(100vh - 48px)',
overflowY: 'auto',
};
+90
View File
@@ -0,0 +1,90 @@
import { getCurrentVersionForTask, getDeadlineSourceSubmission } from './taskDeadlines';
import { serviceTypes } from '../data/mockData';
export const REJECT_NOTE_PREFIX = '[rejected-note]';
export const REVIEW_SHADOW_PREFIX = '[review-shadow]';
const SERVICE_TYPE_SET = new Set(serviceTypes);
function isRecognizedServiceType(value) {
const normalized = String(value || '').trim();
return normalized ? SERVICE_TYPE_SET.has(normalized) : false;
}
export function parseRejectedNote(body = '') {
if (!String(body).startsWith(REJECT_NOTE_PREFIX)) return null;
const rest = String(body).slice(REJECT_NOTE_PREFIX.length);
const [versionRaw, ...noteParts] = rest.split('::');
const versionNumber = Number(versionRaw);
if (!Number.isFinite(versionNumber)) return null;
return { versionNumber, body: noteParts.join('::').trim() };
}
export function encodeRejectedNote(versionNumber, body) {
return `${REJECT_NOTE_PREFIX}${versionNumber}::${body}`;
}
export function isReviewShadowSubmission(submission) {
return String(submission?.description || '').startsWith(REVIEW_SHADOW_PREFIX);
}
export function isReviewShadowDescription(description = '') {
return String(description).startsWith(REVIEW_SHADOW_PREFIX);
}
export function getVisibleTaskSubmissions(submissions = []) {
return (submissions || []).filter(submission => !isReviewShadowSubmission(submission));
}
export function getLatestVisibleSubmissionForVersion(taskId, submissions = [], versionNumber = 0) {
return getVisibleTaskSubmissions(submissions)
.filter(submission => submission?.task_id === taskId && (submission?.version_number || 0) === versionNumber)
.sort((a, b) => new Date(b.submitted_at || 0).getTime() - new Date(a.submitted_at || 0).getTime())[0] || null;
}
export function getLatestDeliveryForVersion(taskId, submissions = [], deliveries = [], versionNumber = 0) {
const versionSubmissionIds = new Set(
(submissions || [])
.filter(submission => submission?.task_id === taskId && (submission?.version_number || 0) === versionNumber)
.map(submission => submission.id)
);
if (versionSubmissionIds.size === 0) return null;
return (deliveries || [])
.filter(delivery => versionSubmissionIds.has(delivery?.submission_id))
.sort((a, b) => new Date(b.sent_at || 0).getTime() - new Date(a.sent_at || 0).getTime())[0] || null;
}
export function getTaskDerivedState(task, submissions = [], deliveries = []) {
const taskSubs = (submissions || []).filter(submission => submission?.task_id === task?.id);
const visibleTaskSubs = getVisibleTaskSubmissions(taskSubs);
const currentVersion = getCurrentVersionForTask(task, taskSubs);
const deadlineSource = getDeadlineSourceSubmission(task, taskSubs);
const latestVisibleSubmission = getLatestVisibleSubmissionForVersion(task?.id, taskSubs, currentVersion);
const latestDelivery = getLatestDeliveryForVersion(task?.id, taskSubs, deliveries, currentVersion);
const initialSubmission = visibleTaskSubs.find(submission => submission?.type === 'initial') || null;
const canonicalServiceType =
(isRecognizedServiceType(initialSubmission?.service_type) && initialSubmission?.service_type) ||
visibleTaskSubs.find(submission => isRecognizedServiceType(submission?.service_type))?.service_type ||
(isRecognizedServiceType(deadlineSource?.service_type) && deadlineSource?.service_type) ||
'—';
const deadline = deadlineSource?.deadline || latestVisibleSubmission?.deadline || null;
const isHot = Boolean(deadlineSource?.is_hot || latestVisibleSubmission?.is_hot);
const latestActivityAt = Math.max(
latestVisibleSubmission?.submitted_at ? new Date(latestVisibleSubmission.submitted_at).getTime() : 0,
latestDelivery?.sent_at ? new Date(latestDelivery.sent_at).getTime() : 0,
task?.submitted_at ? new Date(task.submitted_at).getTime() : 0
);
return {
taskSubs,
visibleTaskSubs,
currentVersion,
deadlineSource,
latestVisibleSubmission,
latestDelivery,
initialSubmission,
serviceType: canonicalServiceType,
deadline,
isHot,
latestActivityAt,
};
}
+51
View File
@@ -0,0 +1,51 @@
import { supabase } from './supabase';
export function getCurrentUserCompanyIds(currentUser) {
return [
...(currentUser?.companies?.map((company) => company?.company?.id || company?.id) || []),
...(currentUser?.company_id ? [currentUser.company_id] : []),
...(currentUser?.company?.id ? [currentUser.company.id] : []),
].filter(Boolean).filter((value, index, list) => list.indexOf(value) === index);
}
export async function resolveScopedWorkIds(currentUser, { isClient = false, isExternal = false } = {}) {
let scopedCompanyIds = null;
let scopedProjectIds = null;
let scopedTaskIds = null;
if (isClient) {
scopedCompanyIds = getCurrentUserCompanyIds(currentUser);
if (scopedCompanyIds.length > 0) {
const { data: projectRows } = await supabase
.from('projects')
.select('id, tasks(id)')
.in('company_id', scopedCompanyIds);
scopedProjectIds = (projectRows || []).map((project) => project.id).filter(Boolean);
scopedTaskIds = (projectRows || [])
.flatMap((project) => (project.tasks || []).map((task) => task.id))
.filter(Boolean);
} else {
scopedProjectIds = [];
scopedTaskIds = [];
}
}
if (isExternal) {
const { data: memberRows } = await supabase
.from('project_members')
.select('project_id')
.eq('profile_id', currentUser?.id);
scopedProjectIds = (memberRows || []).map((row) => row.project_id).filter(Boolean);
if (scopedProjectIds.length > 0) {
const { data: taskRows } = await supabase
.from('tasks')
.select('id')
.in('project_id', scopedProjectIds);
scopedTaskIds = (taskRows || []).map((row) => row.id).filter(Boolean);
} else {
scopedTaskIds = [];
}
}
return { scopedCompanyIds, scopedProjectIds, scopedTaskIds };
}
+5 -4
View File
@@ -793,10 +793,11 @@ export default function BrandBook() {
{loadingBooks ? ( {loadingBooks ? (
<p style={{ padding: '24px 0', color: 'var(--text-muted)' }}>Loading...</p> <p style={{ padding: '24px 0', color: 'var(--text-muted)' }}>Loading...</p>
) : filteredBooks.length === 0 ? ( ) : filteredBooks.length === 0 ? (
<div className="empty-state"> <div className="card" style={{ minHeight: 160, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12 }}>
<h3>{savedBooks.length === 0 ? 'No brand books yet' : 'No matching brand books'}</h3> <div style={{ fontSize: 13, color: 'var(--text-muted)', textAlign: 'center' }}>
<p>{savedBooks.length === 0 ? 'Create your first brand book to get started.' : 'Try clearing the current company filter.'}</p> {savedBooks.length === 0 ? 'No brand books' : 'No matching brand books'}
<button className="btn btn-primary" onClick={handleNew} style={{ marginTop: 16 }}>+ New Brand Book</button> </div>
{savedBooks.length === 0 && <button className="btn btn-primary" onClick={handleNew}>+ New Brand Book</button>}
</div> </div>
) : ( ) : (
<div className="table-wrapper"> <div className="table-wrapper">
+41 -182
View File
@@ -1,13 +1,14 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import Layout from '../components/Layout'; import Layout from '../components/Layout';
import PageLoader from '../components/PageLoader';
import SortTh from '../components/SortTh'; import SortTh from '../components/SortTh';
import { useSortable } from '../hooks/useSortable'; import { useSortable } from '../hooks/useSortable';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { deleteCompanyData } from '../lib/deleteHelpers'; import { deleteCompanyData } from '../lib/deleteHelpers';
import { readPageCache, writePageCache } from '../lib/pageCache'; import { readPageCache, writePageCache } from '../lib/pageCache';
import { createClientFolder, backfillClientFolders, createTeamMemberFolder } from '../lib/filebrowserFolders'; import { ensureCompanyFolder, ensureSubcontractorFolder, renameSubcontractorFolder } from '../lib/folderSync';
// Team view // Team view
@@ -63,8 +64,11 @@ function TeamCompanies() {
}).select().single(); }).select().single();
setSaving(false); setSaving(false);
if (data) { if (data) {
createClientFolder(data.name).catch(() => {}); try {
backfillClientFolders().catch(() => {}); await ensureCompanyFolder({ companyId: data.id });
} catch (folderError) {
console.error('Company folder sync failed:', folderError);
}
setShowNew(false); setShowNew(false);
setNewForm({ name: '', phone: '', address: '' }); setNewForm({ name: '', phone: '', address: '' });
navigate(`/company/${data.id}`); navigate(`/company/${data.id}`);
@@ -80,8 +84,18 @@ function TeamCompanies() {
const handleEditUserSave = async (userId) => { const handleEditUserSave = async (userId) => {
if (!editUserVal.trim()) return; if (!editUserVal.trim()) return;
await supabase.from('profiles').update({ name: editUserVal.trim() }).eq('id', userId); const existingUser = profiles.find(u => u.id === userId);
setProfiles(prev => prev.map(u => u.id === userId ? { ...u, name: editUserVal.trim() } : u)); const oldName = existingUser?.name || '';
const newName = editUserVal.trim();
await supabase.from('profiles').update({ name: newName }).eq('id', userId);
if (existingUser?.role === 'external') {
try {
await renameSubcontractorFolder({ profileId: userId, oldName, newName });
} catch (folderError) {
console.error('Subcontractor folder rename failed:', folderError);
}
}
setProfiles(prev => prev.map(u => u.id === userId ? { ...u, name: newName } : u));
setEditingUserId(null); setEditingUserId(null);
}; };
@@ -113,15 +127,31 @@ function TeamCompanies() {
const errBody = error?.context ? await error.context.json().catch(() => null) : null; const errBody = error?.context ? await error.context.json().catch(() => null) : null;
const errMsg = errBody?.error || data?.error || error?.message; const errMsg = errBody?.error || data?.error || error?.message;
if (errMsg) { setUserError(errMsg); return; } if (errMsg) { setUserError(errMsg); return; }
if (userForm.role === 'external') {
try {
const { data: newProfile } = await supabase
.from('profiles')
.select('id')
.eq('email', userForm.email.trim())
.eq('role', 'external')
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
if (newProfile?.id) {
await ensureSubcontractorFolder({ profileId: newProfile.id });
}
} catch (folderError) {
console.error('Subcontractor folder sync failed:', folderError);
}
}
if (userForm.role === 'team' || userForm.role === 'external') { if (userForm.role === 'team' || userForm.role === 'external') {
createTeamMemberFolder(userForm.name.trim()).catch(() => {});
} }
setShowNewUser(false); setShowNewUser(false);
setUserForm({ name: '', email: '', password: '', company_id: '', role: 'client' }); setUserForm({ name: '', email: '', password: '', company_id: '', role: 'client' });
load(); load();
}; };
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>; if (loading) return <Layout><PageLoader /></Layout>;
const getProfileCompanyIds = (profile) => { const getProfileCompanyIds = (profile) => {
const ids = new Set( const ids = new Set(
@@ -209,7 +239,7 @@ function TeamCompanies() {
)} )}
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}> <div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{companies.length === 0 ? ( {companies.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '8px 0' }}>No clients yet.</div> <div className="card-empty-center">No clients</div>
) : ( ) : (
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}> <div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table> <table>
@@ -363,7 +393,7 @@ function TeamCompanies() {
</div> </div>
)} )}
{clientProfiles.length === 0 ? ( {clientProfiles.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '8px 0' }}>No users yet.</div> <div className="card-empty-center">No users</div>
) : ( ) : (
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}> <div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table> <table>
@@ -417,7 +447,7 @@ function TeamCompanies() {
{userSubTab === 'external' && <> {userSubTab === 'external' && <>
{subcontractors.length === 0 ? ( {subcontractors.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '8px 0' }}>No subcontractors yet.</div> <div className="card-empty-center">No subcontractors</div>
) : ( ) : (
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}> <div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table> <table>
@@ -484,7 +514,7 @@ function ClientCompanyList() {
</div> </div>
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}> <div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{companies.length === 0 ? ( {companies.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '8px 0' }}>No companies linked to your account.</div> <div className="card-empty-center">No companies</div>
) : ( ) : (
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}> <div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table> <table>
@@ -512,177 +542,6 @@ function ClientCompanyList() {
); );
} }
// (removed old ClientCompanies dropdown kept for reference only)
function _UnusedClientCompanies() {
const { currentUser } = useAuth();
const companies = currentUser?.companies || [];
const [selectedId, setSelectedId] = useState(companies[0]?.id || null);
const company = companies.find(c => c.id === selectedId) || companies[0] || null;
const [members, setMembers] = useState([]);
const [loading, setLoading] = useState(!!company?.id);
const [editing, setEditing] = useState(false);
const [form, setForm] = useState({ name: company?.name || '', phone: company?.phone || '', address: company?.address || '' });
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!company?.id) return;
setForm({ name: company.name || '', phone: company.phone || '', address: company.address || '' });
setEditing(false);
setLoading(true);
async function load() {
const [{ data: primaryMembers }, { data: memberRows }] = await Promise.all([
supabase.from('profiles').select('id, name, email').eq('company_id', company.id).in('role', ['client', 'external']),
supabase.from('company_members').select('profile:profiles(id, name, email)').eq('company_id', company.id),
]);
const memberMap = new Map();
(primaryMembers || []).forEach(m => memberMap.set(m.id, m));
(memberRows || []).forEach(row => { if (row.profile) memberMap.set(row.profile.id, row.profile); });
setMembers([...memberMap.values()]);
setLoading(false);
}
load();
}, [company?.id]);
const handleSave = async (e) => {
e.preventDefault();
setSaving(true);
const { error } = await supabase.from('companies').update({
name: form.name.trim(),
phone: form.phone.trim(),
address: form.address.trim(),
}).eq('id', company.id);
setSaving(false);
if (error) { alert('Failed to save. Please try again.'); return; }
setEditing(false);
};
if (!company) return (
<Layout>
<div className="page-header"><div className="page-title">My Company</div></div>
<p style={{ color: 'var(--text-muted)' }}>No company linked to your account.</p>
</Layout>
);
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
const companyDetails = [
{ label: 'Company Name', value: form.name || company.name || '—' },
{ label: 'Phone', value: company.phone || '—' },
{ label: 'Address', value: company.address || '—' },
{ label: 'Members', value: String(members.length) },
];
return (
<Layout>
<div className="page-header">
<div>
{companies.length > 1 ? (
<div style={{ marginBottom: 4 }}>
<select
value={selectedId}
onChange={e => setSelectedId(e.target.value)}
style={{
fontSize: 22, fontWeight: 400, background: 'var(--card-bg)',
border: '1px solid var(--border)', borderRadius: 4,
color: 'var(--text-primary)', cursor: 'pointer',
padding: '4px 8px', fontFamily: 'inherit',
}}
>
{companies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
) : (
<div className="page-title">{form.name || company.name}</div>
)}
<div className="page-subtitle">
{[company.phone, company.address].filter(Boolean).join(' · ') || 'No contact info on file'}
</div>
</div>
{!editing && (
<button className="btn btn-outline" onClick={() => setEditing(true)}>Edit Info</button>
)}
</div>
<div className="stats-grid" style={{ marginBottom: 24 }}>
{companyDetails.map(detail => (
<div key={detail.label} className={`stat-card${detail.label === 'Members' ? ' stat-card-highlight' : ''}`}>
<div className="stat-value" style={{ fontSize: detail.label === 'Members' ? 28 : 18 }}>{detail.value}</div>
<div className="stat-label">{detail.label}</div>
</div>
))}
</div>
{editing && (
<div className="card" style={{ marginBottom: 24, maxWidth: 520 }}>
<div className="card-title">Edit Company Info</div>
<form onSubmit={handleSave}>
<div className="form-group">
<label>Company Name *</label>
<input type="text" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} required />
</div>
<div className="grid-2">
<div className="form-group">
<label>Phone</label>
<input type="text" placeholder="+1 (555) 000-0000" value={form.phone}
onChange={e => setForm(f => ({ ...f, phone: e.target.value }))} />
</div>
<div className="form-group">
<label>Address</label>
<input type="text" placeholder="123 Main St, City, State" value={form.address}
onChange={e => setForm(f => ({ ...f, address: e.target.value }))} />
</div>
</div>
<div className="action-buttons">
<button type="submit" className="btn btn-primary" disabled={saving || !form.name.trim()}>
{saving ? 'Saving...' : 'Save Changes'}
</button>
<button type="button" className="btn btn-outline" onClick={() => {
setEditing(false);
setForm({ name: company.name || '', phone: company.phone || '', address: company.address || '' });
}}>Cancel</button>
</div>
</form>
</div>
)}
<div className="card">
<div className="card-title">People</div>
{members.length === 0 ? (
<div className="card-empty-center">No members found.</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column' }}>
{members.map((member, i) => (
<div key={member.id} style={{
display: 'flex', alignItems: 'center', gap: 12, padding: '12px 0',
borderBottom: i < members.length - 1 ? '1px solid var(--border)' : 'none',
}}>
<div style={{
width: 36, height: 36, borderRadius: 4, background: 'var(--accent)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 13, fontWeight: 400, color: '#111', flexShrink: 0,
}}>
{member.name?.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
</div>
<div>
<div style={{ fontWeight: 400, fontSize: 14, color: 'var(--text-primary)' }}>
{member.name}
{member.id === currentUser.id && (
<span style={{ marginLeft: 8, fontSize: 11, color: 'var(--accent)', fontWeight: 500 }}>You</span>
)}
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{member.email || '—'}</div>
</div>
</div>
))}
</div>
)}
</div>
</Layout>
);
}
// Entry point // Entry point
export default function CompaniesPage() { export default function CompaniesPage() {
+21 -14
View File
@@ -1,13 +1,14 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom'; import { useParams, Link, useNavigate } from 'react-router-dom';
import Layout from '../components/Layout'; import Layout from '../components/Layout';
import PageLoader from '../components/PageLoader';
import StatusBadge from '../components/StatusBadge'; import StatusBadge from '../components/StatusBadge';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { serviceTypes } from '../data/mockData'; import { serviceTypes } from '../data/mockData';
import { cleanupTaskStorage, deleteCompanyData } from '../lib/deleteHelpers'; import { cleanupTaskStorage, deleteCompanyData } from '../lib/deleteHelpers';
import { logActivity } from '../lib/activityLog'; import { logActivity } from '../lib/activityLog';
import { createProjectFolder } from '../lib/filebrowserFolders'; import { ensureProjectFolder, renameCompanyFolder } from '../lib/folderSync';
export default function CompanyDetail() { export default function CompanyDetail() {
const { id } = useParams(); const { id } = useParams();
@@ -78,8 +79,14 @@ export default function CompanyDetail() {
if (!nameVal.trim()) return; if (!nameVal.trim()) return;
setSavingName(true); setSavingName(true);
const oldName = company.name; const oldName = company.name;
await supabase.from('companies').update({ name: nameVal.trim() }).eq('id', id); const newName = nameVal.trim();
setCompany(c => ({ ...c, name: nameVal.trim() })); await supabase.from('companies').update({ name: newName }).eq('id', id);
try {
await renameCompanyFolder({ companyId: id, oldName, newName });
} catch (folderError) {
console.error('Company folder rename failed:', folderError);
}
setCompany(c => ({ ...c, name: newName }));
setEditingName(false); setEditingName(false);
setSavingName(false); setSavingName(false);
}; };
@@ -179,8 +186,12 @@ export default function CompanyDetail() {
status: 'active', status: 'active',
}).select().single(); }).select().single();
if (data) { if (data) {
try {
await ensureProjectFolder({ projectId: data.id });
} catch (folderError) {
console.error('Project folder sync failed:', folderError);
}
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'project_created', projectId: data.id, projectName: data.name }); logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'project_created', projectId: data.id, projectName: data.name });
createProjectFolder(company.name, data.name).catch(() => {});
setProjects(prev => [data, ...prev]); setProjects(prev => [data, ...prev]);
setNewProjectName(''); setNewProjectName('');
setShowNewProject(false); setShowNewProject(false);
@@ -248,7 +259,7 @@ export default function CompanyDetail() {
setSavingSignFamilyPrice(null); setSavingSignFamilyPrice(null);
}; };
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>; if (loading) return <Layout><PageLoader /></Layout>;
if (!company) return <Layout><p>Company not found.</p></Layout>; if (!company) return <Layout><p>Company not found.</p></Layout>;
const activeTasks = tasks.filter(t => t.status !== 'client_approved'); const activeTasks = tasks.filter(t => t.status !== 'client_approved');
@@ -318,15 +329,14 @@ export default function CompanyDetail() {
</div> </div>
{/* Tabs */} {/* Tabs */}
<div style={{ display: 'flex', gap: 4, marginBottom: 24, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 4, marginBottom: 10, flexWrap: 'wrap', minHeight: 'var(--btn-height)' }}>
{(isTeam ? ['users', 'projects', 'pricing'] : ['users', 'projects']).map(t => ( {(isTeam ? ['users', 'projects', 'pricing'] : ['users', 'projects']).map(t => (
<button <button
key={t} key={t}
onClick={() => setTab(t)} onClick={() => setTab(t)}
className={`tab-btn${tab === t ? ' active' : ''}`} className={`section-tab-btn${tab === t ? ' is-active' : ''}`}
style={{ textTransform: 'capitalize' }}
> >
{t} {t.charAt(0).toUpperCase() + t.slice(1)}
{t === 'users' && availableUsers.length > 0 && ( {t === 'users' && availableUsers.length > 0 && (
<span style={{ marginLeft: 6, fontSize: 10, background: tab === t ? 'rgba(0,0,0,0.3)' : 'var(--danger)', color: 'white', padding: '1px 5px', borderRadius: 4, fontWeight: 400 }}> <span style={{ marginLeft: 6, fontSize: 10, background: tab === t ? 'rgba(0,0,0,0.3)' : 'var(--danger)', color: 'white', padding: '1px 5px', borderRadius: 4, fontWeight: 400 }}>
{availableUsers.length} {availableUsers.length}
@@ -342,7 +352,7 @@ export default function CompanyDetail() {
<div className="card"> <div className="card">
<div className="card-title">Assigned Users</div> <div className="card-title">Assigned Users</div>
{users.length === 0 ? ( {users.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>No users assigned to this company yet.</p> <div className="card-empty-center">No users</div>
) : ( ) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{users.map((user, i) => ( {users.map((user, i) => (
@@ -494,10 +504,7 @@ export default function CompanyDetail() {
)} )}
{projects.length === 0 ? ( {projects.length === 0 ? (
<div className="empty-state"> <div className="card card-empty-center">No projects</div>
<h3>No projects yet</h3>
<p>Create a project to start adding jobs.</p>
</div>
) : ( ) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{projects.map(project => { {projects.map(project => {
+13 -3
View File
@@ -1,6 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import JSZip from 'jszip'; import JSZip from 'jszip';
import { heicTo, isHeic } from 'heic-to/csp';
import Layout from '../components/Layout'; import Layout from '../components/Layout';
import LoadingButton from '../components/LoadingButton'; import LoadingButton from '../components/LoadingButton';
@@ -13,6 +12,14 @@ const OUTPUT_FORMATS = [
const HEIC_EXTENSIONS = new Set(['heic', 'heif']); const HEIC_EXTENSIONS = new Set(['heic', 'heif']);
const MAX_FILES = 100; const MAX_FILES = 100;
// Lazy-load heic-to (WASM, ~2.7MB) only when a conversion actually runs,
// so visiting the page doesn't pull the whole library up front.
let _heicLibPromise = null;
function getHeicLib() {
if (!_heicLibPromise) _heicLibPromise = import('heic-to/csp');
return _heicLibPromise;
}
function getExtension(name = '') { function getExtension(name = '') {
return name.split('.').pop()?.toLowerCase() || ''; return name.split('.').pop()?.toLowerCase() || '';
} }
@@ -96,6 +103,7 @@ async function convertRasterBlob(blob, format, quality) {
} }
async function convertHeicFile(file, format, quality) { async function convertHeicFile(file, format, quality) {
const { heicTo } = await getHeicLib();
if (format.mime === 'image/jpeg' || format.mime === 'image/png') { if (format.mime === 'image/jpeg' || format.mime === 'image/png') {
try { try {
return await heicTo({ blob: file, type: format.mime, quality }); return await heicTo({ blob: file, type: format.mime, quality });
@@ -129,7 +137,9 @@ async function convertHeicFile(file, format, quality) {
async function convertFile(file, format, quality) { async function convertFile(file, format, quality) {
const looksHeic = isHeicFile(file); const looksHeic = isHeicFile(file);
const confirmedHeic = looksHeic ? await isHeic(file).catch(() => false) : false; const confirmedHeic = looksHeic
? await getHeicLib().then(({ isHeic }) => isHeic(file)).catch(() => false)
: false;
if (looksHeic || confirmedHeic) { if (looksHeic || confirmedHeic) {
return convertHeicFile(file, format, quality); return convertHeicFile(file, format, quality);
} }
@@ -368,7 +378,7 @@ export default function Converters() {
</div> </div>
{!files.length ? ( {!files.length ? (
<div style={{ color: 'var(--text-muted)', fontSize: 13 }}>No files added yet.</div> <div className="card-empty-center">No files</div>
) : ( ) : (
<div style={{ display: 'grid', gap: 12 }}> <div style={{ display: 'grid', gap: 12 }}>
{files.map((file, index) => { {files.map((file, index) => {
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useParams, useSearchParams } from 'react-router-dom'; import { useParams, useSearchParams } from 'react-router-dom';
import PageLoader from '../components/PageLoader';
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY; const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
@@ -74,15 +75,14 @@ export default function PayInvoice() {
return ( return (
<div style={{ minHeight: '100vh', background: '#f5f5f5', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: 24 }}> <div style={{ minHeight: '100vh', background: '#f5f5f5', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: 24 }}>
{loading ? <PageLoader /> : null}
<div style={{ width: '100%', maxWidth: 480 }}> <div style={{ width: '100%', maxWidth: 480 }}>
{/* Logo */} {/* Logo */}
<div style={{ textAlign: 'center', marginBottom: 32 }}> <div style={{ textAlign: 'center', marginBottom: 32 }}>
<img src="/fourge-logo.png" alt="Fourge Branding" style={{ height: 36, filter: 'invert(1)' }} /> <img src="/fourge-logo.png" alt="Fourge Branding" style={{ height: 36, filter: 'invert(1)' }} />
</div> </div>
{loading ? ( {!invoice ? (
<div style={{ textAlign: 'center', color: '#666' }}>Loading...</div>
) : !invoice ? (
<div style={{ background: '#fff', color: '#141414', borderRadius: 4, padding: 32, textAlign: 'center', boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}> <div style={{ background: '#fff', color: '#141414', borderRadius: 4, padding: 32, textAlign: 'center', boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}>
<div style={{ fontSize: 18, fontWeight: 400, marginBottom: 8 }}>Invoice not found</div> <div style={{ fontSize: 18, fontWeight: 400, marginBottom: 8 }}>Invoice not found</div>
<div style={{ color: '#666' }}>This payment link may be invalid or expired.</div> <div style={{ color: '#666' }}>This payment link may be invalid or expired.</div>
+1 -1
View File
@@ -689,7 +689,7 @@ export default function ProfilePage() {
<input type="text" value={editForm.linkedin} onChange={setEditField('linkedin')} placeholder="linkedin.com/in/username" style={modalInputStyle} /> <input type="text" value={editForm.linkedin} onChange={setEditField('linkedin')} placeholder="linkedin.com/in/username" style={modalInputStyle} />
</div> </div>
{editError && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>{editError}</div>} {editError && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>{editError}</div>}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 6 }}> <div className="modal-action-row" style={{ marginTop: 6 }}>
<button type="submit" className="btn btn-outline" disabled={savingProfile} style={modalButtonStyle}> <button type="submit" className="btn btn-outline" disabled={savingProfile} style={modalButtonStyle}>
{savingProfile ? 'Saving...' : 'Save'} {savingProfile ? 'Saving...' : 'Save'}
</button> </button>
+108 -166
View File
@@ -1,34 +1,35 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom'; import { useParams, Link, useNavigate } from 'react-router-dom';
import Layout from '../components/Layout'; import Layout from '../components/Layout';
import PageLoader from '../components/PageLoader'; import PageLoader from '../components/PageLoader';
import ProfileAvatar from '../components/ProfileAvatar';
import StatusBadge from '../components/StatusBadge'; import StatusBadge from '../components/StatusBadge';
import SortTh from '../components/SortTh'; import SortTh from '../components/SortTh';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { logActivity } from '../lib/activityLog'; import { logActivity } from '../lib/activityLog';
import { createTaskFolder, uploadFilesToRequestInfo } from '../lib/filebrowserFolders';
import { createInitialSubmissionForRequest, createTaskForRequest } from '../lib/requestSubmission'; import { createInitialSubmissionForRequest, createTaskForRequest } from '../lib/requestSubmission';
import { ensureRequestFolder, renameProjectFolder, uploadRequestFileToSurvey } from '../lib/folderSync';
import { sendEmail } from '../lib/email'; import { sendEmail } from '../lib/email';
import RequestForm from '../components/RequestForm'; import RequestForm from '../components/RequestForm';
import { useSortable } from '../hooks/useSortable'; import { useSortable } from '../hooks/useSortable';
import { serviceTypes } from '../data/mockData'; import { fmtShortDate } from '../lib/dates';
import { addDaysToDateOnly, getTodayDateOnlyEST, fmtShortDate } from '../lib/dates'; import { popupOverlayStyle } from '../lib/popupStyles';
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles'; import { useLiveRefresh } from '../hooks/useLiveRefresh';
import { useRefetchOnFocus } from '../hooks/useRefetchOnFocus'; import { getTaskDerivedState } from '../lib/taskVersions';
import { useRealtimeSubscription } from '../hooks/useRealtimeSubscription'; import {
TASK_TABLE_TH_STYLE,
TASK_TABLE_TD_BASE,
TASK_MODAL_TITLE_STYLE,
TASK_MODAL_SHELL_STYLE,
} from '../lib/taskUi';
const CARD = { padding: '18px 21px', borderRadius: 8 }; const CARD = { padding: '18px 21px', borderRadius: 8 };
const LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 14 }; const LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 14 };
const META_LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 4 }; const META_LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 4 };
const CARD_META_LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 2 }; const CARD_META_LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 2 };
const MODAL_LBL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 6 };
const TH_STYLE = { fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.6, color: 'var(--text-muted)', textAlign: 'left', padding: '0 0 12px 5px', border: 'none', background: 'transparent', verticalAlign: 'top' };
const TD_BASE = { padding: '5px', border: 'none', background: 'transparent', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
const MODAL_IN = { fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' }; const MODAL_IN = { fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' };
const emptyJobForm = () => ({ title: '', serviceType: '', deadline: addDaysToDateOnly(getTodayDateOnlyEST(), 3), description: '', requestedBy: '' });
export default function ProjectDetailPage() { export default function ProjectDetailPage() {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -38,28 +39,22 @@ export default function ProjectDetailPage() {
const isExternal = currentUser?.role === 'external'; const isExternal = currentUser?.role === 'external';
const isTeam = currentUser?.role === 'team'; const isTeam = currentUser?.role === 'team';
const [refreshKey, setRefreshKey] = useState(0); const { refreshKey } = useLiveRefresh(['tasks', 'projects', 'project_members', 'profiles']);
const refresh = useCallback(() => setRefreshKey(k => k + 1), []);
useRefetchOnFocus(refresh);
useRealtimeSubscription(['tasks', 'projects', 'project_members', 'profiles'], refresh);
const [project, setProject] = useState(null); const [project, setProject] = useState(null);
const [company, setCompany] = useState(null); const [company, setCompany] = useState(null);
const [tasks, setTasks] = useState([]); const [tasks, setTasks] = useState([]);
const [members, setMembers] = useState([]); const [members, setMembers] = useState([]);
const [externalProfiles, setExtProfs] = useState([]); const [externalProfiles, setExtProfs] = useState([]);
const [companyUsers, setCompanyUsers] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [submissions, setSubmissions] = useState([]); const [submissions, setSubmissions] = useState([]);
const [deliveries, setDeliveries] = useState([]);
const [activeTab, setActiveTab] = useState('all'); const [activeTab, setActiveTab] = useState('all');
const [editingName, setEditingName] = useState(false); const [editingName, setEditingName] = useState(false);
const [nameVal, setNameVal] = useState(''); const [nameVal, setNameVal] = useState('');
const [savingName, setSavingName] = useState(false); const [savingName, setSavingName] = useState(false);
const [showAddJob, setShowAddJob] = useState(false);
const [jobForm, setJobForm] = useState(emptyJobForm());
const [savingJob, setSavingJob] = useState(false);
const [showClientTask, setShowClientTask] = useState(false); const [showClientTask, setShowClientTask] = useState(false);
const [clientTaskKey, setClientTaskKey] = useState(0); const [clientTaskKey, setClientTaskKey] = useState(0);
const [clientTaskSaving, setClientTaskSaving] = useState(false); const [clientTaskSaving, setClientTaskSaving] = useState(false);
@@ -93,13 +88,20 @@ export default function ProjectDetailPage() {
if (taskIds.length > 0) { if (taskIds.length > 0) {
const { data: subs } = await supabase.from('submissions').select('id, task_id, type, version_number, service_type, deadline, is_hot, submitted_at').in('task_id', taskIds).order('submitted_at', { ascending: false }); const { data: subs } = await supabase.from('submissions').select('id, task_id, type, version_number, service_type, deadline, is_hot, submitted_at').in('task_id', taskIds).order('submitted_at', { ascending: false });
setSubmissions(subs || []); setSubmissions(subs || []);
if ((subs || []).length > 0) {
const { data: delRows } = await supabase.from('deliveries').select('id, submission_id, version_number, sent_at').in('submission_id', (subs || []).map(sub => sub.id));
setDeliveries(delRows || []);
} else {
setDeliveries([]);
}
} else {
setDeliveries([]);
} }
const [{ data: users }, { data: pm }, { data: viaMembers }] = await Promise.all([ const [{ data: users }, { data: pm }, { data: viaMembers }] = await Promise.all([
supabase.from('profiles').select('id, name, avatar_url, email').eq('company_id', p.company_id).eq('role', 'client'), supabase.from('profiles').select('id, name, avatar_url, email').eq('company_id', p.company_id).eq('role', 'client'),
supabase.from('project_members').select('*, profile:profiles(id, name, avatar_url, email, role)').eq('project_id', id), supabase.from('project_members').select('*, profile:profiles(id, name, avatar_url, email, role)').eq('project_id', id),
supabase.from('company_members').select('profile:profiles(id, name, avatar_url, email, role)').eq('company_id', p.company_id), supabase.from('company_members').select('profile:profiles(id, name, avatar_url, email, role)').eq('company_id', p.company_id),
]); ]);
setCompanyUsers(users || []);
setMembers(pm || []); setMembers(pm || []);
const seen = new Set(); const seen = new Set();
const merged = []; const merged = [];
@@ -128,8 +130,15 @@ export default function ProjectDetailPage() {
e.preventDefault(); e.preventDefault();
if (!nameVal.trim()) return; if (!nameVal.trim()) return;
setSavingName(true); setSavingName(true);
await supabase.from('projects').update({ name: nameVal.trim() }).eq('id', id); const oldName = project.name;
setProject(p => ({ ...p, name: nameVal.trim() })); const newName = nameVal.trim();
await supabase.from('projects').update({ name: newName }).eq('id', id);
try {
await renameProjectFolder({ projectId: id, oldName, newName });
} catch (folderError) {
console.error('Project folder rename failed:', folderError);
}
setProject(p => ({ ...p, name: newName }));
setEditingName(false); setEditingName(false);
setSavingName(false); setSavingName(false);
}; };
@@ -151,23 +160,6 @@ export default function ProjectDetailPage() {
setTasks(prev => prev.filter(t => t.id !== taskId)); setTasks(prev => prev.filter(t => t.id !== taskId));
}; };
const handleAddJob = async (e) => {
e.preventDefault();
setSavingJob(true);
const requestor = requesterOptions.find(u => u.id === jobForm.requestedBy);
if (!requestor) { setSavingJob(false); return; }
const { data: task } = await supabase.from('tasks').insert({ project_id: id, title: jobForm.title.trim(), status: 'not_started', current_version: 0 }).select().single();
if (task) {
await supabase.from('submissions').insert({ task_id: task.id, version_number: 0, type: 'initial', service_type: jobForm.serviceType, deadline: jobForm.deadline || null, description: jobForm.description.trim() || null, submitted_by: requestor.id, submitted_by_name: requestor.name.replace(' (You)', '') });
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'task_created', taskId: task.id, taskTitle: task.title, projectId: id, projectName: project?.name });
createTaskFolder(company?.name, project?.name, task.title).catch(() => {});
setTasks(prev => [task, ...prev]);
setJobForm(emptyJobForm());
setShowAddJob(false);
}
setSavingJob(false);
};
const handleAddMember = async () => { const handleAddMember = async () => {
setSavingMembers(true); setSavingMembers(true);
const currentIds = new Set(members.filter(m => m.profile?.role === 'external').map(m => m.profile_id)); const currentIds = new Set(members.filter(m => m.profile?.role === 'external').map(m => m.profile_id));
@@ -188,18 +180,12 @@ export default function ProjectDetailPage() {
setSavingMembers(false); setSavingMembers(false);
}; };
const handleRemoveMember = async (profileId) => {
await supabase.from('project_members').delete().eq('project_id', id).eq('profile_id', profileId);
setMembers(prev => prev.filter(m => m.profile_id !== profileId));
};
const handleClientTask = async (formData, files) => { const handleClientTask = async (formData, files) => {
if (clientTaskSaving) return; if (clientTaskSaving) return;
setClientTaskSaving(true); setClientTaskError(''); setClientTaskSaving(true); setClientTaskError('');
try { try {
const { task } = await createTaskForRequest({ projectId: project.id, title: formData.title.trim(), requestKey: clientTaskRequestKey }); const { task } = await createTaskForRequest({ projectId: project.id, title: formData.title.trim(), requestKey: clientTaskRequestKey });
if (!task) throw new Error('Failed to create task.'); if (!task) throw new Error('Failed to create task.');
createTaskFolder(company?.name, project.name, formData.title.trim()).catch(() => {});
const { submission } = await createInitialSubmissionForRequest({ const { submission } = await createInitialSubmissionForRequest({
taskId: task.id, requestKey: clientTaskRequestKey, isHot: formData.isHot, taskId: task.id, requestKey: clientTaskRequestKey, isHot: formData.isHot,
serviceType: formData.serviceType, signFamily: formData.signFamily, serviceType: formData.serviceType, signFamily: formData.signFamily,
@@ -207,16 +193,32 @@ export default function ProjectDetailPage() {
deadline: formData.deadline, description: formData.description, deadline: formData.deadline, description: formData.description,
submittedBy: currentUser.id, submittedByName: currentUser.name, submittedBy: currentUser.id, submittedByName: currentUser.name,
}); });
try {
await ensureRequestFolder({ projectId: project.id, taskTitle: formData.title.trim() });
} catch (folderError) {
console.error('Request folder sync failed:', folderError);
}
if (submission && files.length > 0) { if (submission && files.length > 0) {
for (const file of files) { for (const file of files) {
const path = `${task.id}/${Date.now()}_${file.name}`; const path = `${task.id}/Survey/${Date.now()}_${file.name}`;
const { data: up } = await supabase.storage.from('submissions').upload(path, file); const { data: up } = await supabase.storage.from('submissions').upload(path, file);
if (up) await supabase.from('submission_files').insert({ submission_id: submission.id, name: file.name, storage_path: path, size: file.size }); if (up) {
await supabase.from('submission_files').insert({ submission_id: submission.id, name: file.name, storage_path: path, size: file.size });
try {
await uploadRequestFileToSurvey({
projectId: project.id,
taskTitle: formData.title.trim(),
storagePath: path,
fileName: file.name,
});
} catch (mirrorError) {
console.error('Survey folder mirror failed:', mirrorError);
}
}
} }
uploadFilesToRequestInfo(files, company?.name, project.name, formData.title.trim()).catch(() => {});
} }
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'request_submitted', taskId: task.id, taskTitle: task.title, projectId: project.id, projectName: project.name }).catch(() => {}); logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'request_submitted', taskId: task.id, taskTitle: task.title, projectId: project.id, projectName: project.name }).catch(() => {});
sendEmail('new_request', 'hello@fourgebranding.com', { clientName: currentUser.name, clientEmail: currentUser.email, company: company?.name || '', serviceType: formData.serviceType, projectName: project.name, deadline: formData.deadline, description: formData.description, taskId: task.id }).catch(() => {}); sendEmail('new_request', 'hello@fourgebranding.com', { clientName: currentUser.name, clientEmail: currentUser.email, company: company?.name || '', serviceType: formData.serviceType, projectName: project.name, projectId: project.id, deadline: formData.deadline, description: formData.description, taskId: task.id }).catch(() => {});
const { data: newTasks } = await supabase.from('tasks').select('*, assignee:profiles!assigned_to(avatar_url)').eq('project_id', id).order('submitted_at', { ascending: false }); const { data: newTasks } = await supabase.from('tasks').select('*, assignee:profiles!assigned_to(avatar_url)').eq('project_id', id).order('submitted_at', { ascending: false });
setTasks(newTasks || []); setTasks(newTasks || []);
setShowClientTask(false); setClientTaskKey(k => k + 1); setClientTaskRequestKey(crypto.randomUUID()); setShowClientTask(false); setClientTaskKey(k => k + 1); setClientTaskRequestKey(crypto.randomUUID());
@@ -227,19 +229,14 @@ export default function ProjectDetailPage() {
if (loading) return <Layout><PageLoader /></Layout>; if (loading) return <Layout><PageLoader /></Layout>;
if (!project) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Project not found.</p></Layout>; if (!project) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Project not found.</p></Layout>;
const requesterOptions = [
...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)` }] : []),
...companyUsers.filter(u => u.id !== currentUser?.id),
];
const rows = tasks.map(task => { const rows = tasks.map(task => {
const sub = submissions.find(s => s.task_id === task.id && s.type === 'initial') || submissions.find(s => s.task_id === task.id); const derived = getTaskDerivedState(task, submissions, deliveries);
return { return {
id: task.id, title: task.title, status: task.status, id: task.id, title: task.title, status: task.status,
version: task.current_version || 0, version: derived.currentVersion,
assignedName: task.assigned_name || null, assigneeAvatar: task.assignee?.avatar_url || null, assignedTo: task.assigned_to || null, assignedName: task.assigned_name || null, assigneeAvatar: task.assignee?.avatar_url || null, assignedTo: task.assigned_to || null,
serviceType: sub?.service_type || '—', deadline: sub?.deadline || null, isHot: sub?.is_hot || false, serviceType: derived.serviceType, deadline: derived.deadline, isHot: derived.isHot,
submittedAt: task.submitted_at || '', submittedAt: derived.latestActivityAt ? new Date(derived.latestActivityAt).toISOString() : (task.submitted_at || ''),
}; };
}); });
@@ -258,7 +255,8 @@ export default function ProjectDetailPage() {
const doneStatuses = new Set(['client_approved', 'invoiced', 'paid']); const doneStatuses = new Set(['client_approved', 'invoiced', 'paid']);
const taskTabs = [ const taskTabs = [
{ id: 'all', label: 'All Tasks' }, { id: 'all', label: 'All Tasks' },
{ id: 'not_started', label: 'To Do' }, { id: 'new_requests', label: 'New Requests' },
{ id: 'revisions', label: 'Revisions' },
{ id: 'in_progress', label: 'In Progress' }, { id: 'in_progress', label: 'In Progress' },
{ id: 'on_hold', label: 'On Hold' }, { id: 'on_hold', label: 'On Hold' },
{ id: 'client_review', label: 'In Review' }, { id: 'client_review', label: 'In Review' },
@@ -267,6 +265,8 @@ export default function ProjectDetailPage() {
]; ];
const visibleRows = activeTab === 'all' ? sortedRows const visibleRows = activeTab === 'all' ? sortedRows
: activeTab === 'completed' ? sortedRows.filter(r => doneStatuses.has(r.status)) : activeTab === 'completed' ? sortedRows.filter(r => doneStatuses.has(r.status))
: activeTab === 'new_requests' ? sortedRows.filter(r => r.status === 'not_started' && Number(r.version || 0) === 0)
: activeTab === 'revisions' ? sortedRows.filter(r => r.status === 'not_started' && Number(r.version || 0) >= 1)
: activeTab === 'members' ? [] : activeTab === 'members' ? []
: sortedRows.filter(r => r.status === activeTab); : sortedRows.filter(r => r.status === activeTab);
@@ -345,12 +345,13 @@ export default function ProjectDetailPage() {
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}> <div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
{(() => { {(() => {
const tabCounts = { const tabCounts = {
all: tasks.length, all: rows.length,
not_started: tasks.filter(t => t.status === 'not_started').length, new_requests: rows.filter(r => r.status === 'not_started' && Number(r.version || 0) === 0).length,
in_progress: tasks.filter(t => t.status === 'in_progress').length, revisions: rows.filter(r => r.status === 'not_started' && Number(r.version || 0) >= 1).length,
on_hold: tasks.filter(t => t.status === 'on_hold').length, in_progress: rows.filter(r => r.status === 'in_progress').length,
client_review: tasks.filter(t => t.status === 'client_review').length, on_hold: rows.filter(r => r.status === 'on_hold').length,
completed: tasks.filter(t => ['client_approved','invoiced','paid'].includes(t.status)).length, client_review: rows.filter(r => r.status === 'client_review').length,
completed: rows.filter(r => ['client_approved','invoiced','paid'].includes(r.status)).length,
members: members.filter(m => m.profile?.role === 'external').length, members: members.filter(m => m.profile?.role === 'external').length,
}; };
return ( return (
@@ -375,7 +376,7 @@ export default function ProjectDetailPage() {
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, overflowY: 'auto' }}> <div className="card" style={{ ...CARD, flex: 1, minHeight: 0, overflowY: 'auto' }}>
{activeTab !== 'members' && ( {activeTab !== 'members' && (
visibleRows.length === 0 visibleRows.length === 0
? <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 1, minHeight: 120, color: 'var(--text-muted)', fontSize: 13 }}>No tasks yet.</div> ? <div className="card-empty-center">No tasks</div>
: <div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden" style={{ overflowY: 'auto' }}> : <div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden" style={{ overflowY: 'auto' }}>
<table className="table-sticky-head" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}> <table className="table-sticky-head" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
<colgroup> <colgroup>
@@ -389,44 +390,40 @@ export default function ProjectDetailPage() {
</colgroup> </colgroup>
<thead> <thead>
<tr> <tr>
<SortTh col="revision" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TH_STYLE}>R#</SortTh> <SortTh col="revision" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TASK_TABLE_TH_STYLE}>R#</SortTh>
<SortTh col="title" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TH_STYLE}>Name</SortTh> <SortTh col="title" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TASK_TABLE_TH_STYLE}>Name</SortTh>
<SortTh col="assigned" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TH_STYLE, textAlign: 'center' }}>Assigned</SortTh> <SortTh col="assigned" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Assigned</SortTh>
<SortTh col="priority" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TH_STYLE, textAlign: 'center' }}>Priority</SortTh> <SortTh col="priority" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Priority</SortTh>
<SortTh col="serviceType" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TH_STYLE, textAlign: 'center' }}>Task Type</SortTh> <SortTh col="serviceType" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Task Type</SortTh>
<SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TH_STYLE, textAlign: 'center' }}>Deadline</SortTh> <SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Deadline</SortTh>
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TH_STYLE, textAlign: 'center' }}>Status</SortTh> <SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Status</SortTh>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{visibleRows.map(row => { {visibleRows.map(row => {
const initials = row.assignedName ? row.assignedName.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase() : null;
const avatarStyle = { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, borderRadius: '50%', overflow: 'hidden', verticalAlign: 'middle', flexShrink: 0 }; const avatarStyle = { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, borderRadius: '50%', overflow: 'hidden', verticalAlign: 'middle', flexShrink: 0 };
return ( return (
<tr key={row.id}> <tr key={row.id}>
<td style={{ ...TD_BASE, fontSize: 12, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}> <td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>
<Link to={`/tasks/${row.id}`} className="table-link">{`R${String(row.version).padStart(2, '0')}`}</Link> <Link to={`/tasks/${row.id}`} className="table-link">{`R${String(row.version).padStart(2, '0')}`}</Link>
</td> </td>
<td style={{ ...TD_BASE, fontSize: 13, color: 'var(--text-primary)' }}> <td style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)' }}>
<Link to={`/tasks/${row.id}`} className="table-link">{row.title || '—'}</Link> <Link to={`/tasks/${row.id}`} className="table-link">{row.title || '—'}</Link>
</td> </td>
<td style={{ ...TD_BASE, textAlign: 'center' }}> <td style={{ ...TASK_TABLE_TD_BASE, textAlign: 'center' }}>
{row.assignedName && row.assignedTo {row.assignedName && row.assignedTo
? <Link to={`/profile/${row.assignedTo}`} style={{ display: 'inline-flex' }}> ? <Link to={`/profile/${row.assignedTo}`} style={{ display: 'inline-flex' }}>
{row.assigneeAvatar <ProfileAvatar name={row.assignedName} avatarUrl={row.assigneeAvatar} size={26} fontSize={10} style={{ verticalAlign: 'middle' }} />
? <div title={row.assignedName} style={avatarStyle}><img src={row.assigneeAvatar} alt={row.assignedName} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /></div>
: <div title={row.assignedName} style={{ ...avatarStyle, background: 'var(--accent)' }}><span style={{ fontSize: 10, fontWeight: 600, color: '#000', lineHeight: 1 }}>{initials}</span></div>
}
</Link> </Link>
: <div title="Unassigned" style={{ ...avatarStyle, background: 'rgba(255,255,255,0.08)' }}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.3)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg></div> : <div title="Unassigned" style={{ ...avatarStyle, background: 'rgba(255,255,255,0.08)' }}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.3)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg></div>
} }
</td> </td>
<td style={{ ...TD_BASE, fontSize: 11, textAlign: 'center', color: row.isHot ? '#ef4444' : 'var(--text-primary)', textTransform: 'uppercase', letterSpacing: 0.5 }}> <td style={{ ...TASK_TABLE_TD_BASE, fontSize: 11, textAlign: 'center', color: row.isHot ? '#ef4444' : 'var(--text-primary)', textTransform: 'uppercase', letterSpacing: 0.5 }}>
{row.isHot ? 'HOT' : 'NO'} {row.isHot ? 'HOT' : 'NO'}
</td> </td>
<td style={{ ...TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>{row.serviceType}</td> <td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>{row.serviceType}</td>
<td style={{ ...TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>{fmtShortDate(row.deadline, 'Not specified')}</td> <td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>{fmtShortDate(row.deadline, 'Not specified')}</td>
<td style={{ ...TD_BASE, textAlign: 'center' }}><StatusBadge status={row.status} /></td> <td style={{ ...TASK_TABLE_TD_BASE, textAlign: 'center' }}><StatusBadge status={row.status} /></td>
</tr> </tr>
); );
})} })}
@@ -439,7 +436,7 @@ export default function ProjectDetailPage() {
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{(() => { {(() => {
const subs = members.filter(m => m.profile?.role === 'external'); const subs = members.filter(m => m.profile?.role === 'external');
if (subs.length === 0) return <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 100, color: 'var(--text-muted)', fontSize: 13 }}>No subcontractors assigned.</div>; if (subs.length === 0) return <div className="card-empty-center">No subcontractors</div>;
const sorted = [...subs].sort((a, b) => { const sorted = [...subs].sort((a, b) => {
if (subSortKey === 'projects') { if (subSortKey === 'projects') {
const av = subProjectCounts[a.profile_id] || 0; const av = subProjectCounts[a.profile_id] || 0;
@@ -461,29 +458,25 @@ export default function ProjectDetailPage() {
</colgroup> </colgroup>
<thead> <thead>
<tr> <tr>
<th style={{ ...TH_STYLE, padding: '0 0 12px' }} /> <th style={{ ...TASK_TABLE_TH_STYLE, padding: '0 0 12px' }} />
<SortTh col="name" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={TH_STYLE}>Name</SortTh> <SortTh col="name" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={TASK_TABLE_TH_STYLE}>Name</SortTh>
<SortTh col="email" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={TH_STYLE}>Email</SortTh> <SortTh col="email" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={TASK_TABLE_TH_STYLE}>Email</SortTh>
<SortTh col="projects" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={{ ...TH_STYLE, textAlign: 'center' }}>Projects</SortTh> <SortTh col="projects" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Projects</SortTh>
<th style={{ ...TH_STYLE, padding: '0 0 12px' }} /> <th style={{ ...TASK_TABLE_TH_STYLE, padding: '0 0 12px' }} />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{sorted.map(m => { {sorted.map(m => {
const initials = (m.profile?.name || '?').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
const projCount = subProjectCounts[m.profile_id] || 0; const projCount = subProjectCounts[m.profile_id] || 0;
return ( return (
<tr key={m.id}> <tr key={m.id}>
<td style={{ ...TD_BASE }}> <td style={{ ...TASK_TABLE_TD_BASE }}>
{m.profile?.avatar_url <ProfileAvatar profile={m.profile} name={m.profile?.name} size={26} fontSize={10} />
? <div style={{ width: 26, height: 26, borderRadius: '50%', overflow: 'hidden' }}><img src={m.profile.avatar_url} alt={m.profile.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /></div>
: <div style={{ width: 26, height: 26, borderRadius: '50%', background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}><span style={{ fontSize: 10, fontWeight: 600, color: '#000', lineHeight: 1 }}>{initials}</span></div>
}
</td> </td>
<td style={{ ...TD_BASE, fontSize: 13, color: 'var(--text-primary)' }}>{m.profile?.name || '—'}</td> <td style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)' }}>{m.profile?.name || '—'}</td>
<td style={{ ...TD_BASE, fontSize: 12, color: 'var(--text-muted)' }}>{m.profile?.email || '—'}</td> <td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-muted)' }}>{m.profile?.email || '—'}</td>
<td style={{ ...TD_BASE, fontSize: 13, color: 'var(--text-primary)', textAlign: 'center', fontVariantNumeric: 'tabular-nums' }}>{projCount}</td> <td style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)', textAlign: 'center', fontVariantNumeric: 'tabular-nums' }}>{projCount}</td>
<td style={{ ...TD_BASE }} /> <td style={{ ...TASK_TABLE_TD_BASE }} />
</tr> </tr>
); );
})} })}
@@ -502,17 +495,13 @@ export default function ProjectDetailPage() {
<div className="card" style={CARD}> <div className="card" style={CARD}>
<div style={LABEL}>Main Contact</div> <div style={LABEL}>Main Contact</div>
{companyClients.length === 0 {companyClients.length === 0
? <div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No contacts found.</div> ? <div className="card-empty-center">No contacts</div>
: ( : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{companyClients.map(person => { {companyClients.map(person => {
const initials = (person.name || '?').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
return ( return (
<div key={person.id} style={{ display: 'flex', alignItems: 'center', gap: 10 }}> <div key={person.id} style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
{person.avatar_url <ProfileAvatar profile={person} name={person.name} size={32} fontSize={12} />
? <div style={{ width: 32, height: 32, borderRadius: '50%', overflow: 'hidden', flexShrink: 0 }}><img src={person.avatar_url} alt={person.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /></div>
: <div style={{ width: 32, height: 32, borderRadius: '50%', background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}><span style={{ fontSize: 12, fontWeight: 600, color: '#000', lineHeight: 1 }}>{initials}</span></div>
}
<div style={{ minWidth: 0 }}> <div style={{ minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)' }}>{person.name}</div> <div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)' }}>{person.name}</div>
{person.email && <div style={{ fontSize: 11, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{person.email}</div>} {person.email && <div style={{ fontSize: 11, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{person.email}</div>}
@@ -530,7 +519,7 @@ export default function ProjectDetailPage() {
{(() => { {(() => {
const SHOW = { task_started: { label: 'started', color: '#60a5fa', bg: 'rgba(96,165,250,0.15)', path: 'M6 4l14 8-14 8V4z' }, task_on_hold: { label: 'placed on hold', color: '#ef4444', bg: 'rgba(239,68,68,0.15)', path: 'M6 4h4v16H6zM14 4h4v16h-4z' }, task_approved: { label: 'approved', color: '#4ade80', bg: 'rgba(74,222,128,0.15)', path: 'M4 13l5 5L20 7' } }; const SHOW = { task_started: { label: 'started', color: '#60a5fa', bg: 'rgba(96,165,250,0.15)', path: 'M6 4l14 8-14 8V4z' }, task_on_hold: { label: 'placed on hold', color: '#ef4444', bg: 'rgba(239,68,68,0.15)', path: 'M6 4h4v16H6zM14 4h4v16h-4z' }, task_approved: { label: 'approved', color: '#4ade80', bg: 'rgba(74,222,128,0.15)', path: 'M4 13l5 5L20 7' } };
const filtered = activityLog; const filtered = activityLog;
if (filtered.length === 0) return <div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No activity yet.</div>; if (filtered.length === 0) return <div className="card-empty-center">No activity</div>;
return ( return (
<div className="scrollbar-thin-theme" style={{ flex: 1, minHeight: 0, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 10 }}> <div className="scrollbar-thin-theme" style={{ flex: 1, minHeight: 0, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 10 }}>
{filtered.map(e => { {filtered.map(e => {
@@ -561,51 +550,10 @@ export default function ProjectDetailPage() {
</div> </div>
</div> </div>
{/* Add Task modal — team only */}
{showAddJob && isTeam && (
<div style={popupOverlayStyle} onClick={() => { setShowAddJob(false); setJobForm(emptyJobForm()); }}>
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(520px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }}>Add Task {project.name}</div>
<form onSubmit={handleAddJob} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<div style={MODAL_LBL}>Task Title *</div>
<input autoFocus required value={jobForm.title} onChange={e => setJobForm(f => ({ ...f, title: e.target.value }))} placeholder="e.g. Logo Design" style={MODAL_IN} />
</div>
<div>
<div style={MODAL_LBL}>Service Type *</div>
<select required value={jobForm.serviceType} onChange={e => setJobForm(f => ({ ...f, serviceType: e.target.value }))} style={MODAL_IN}>
<option value="">Select a service</option>
{serviceTypes.map(s => <option key={s} value={s}>{s}</option>)}
</select>
</div>
<div>
<div style={MODAL_LBL}>Deadline</div>
<input type="date" value={jobForm.deadline} onChange={e => setJobForm(f => ({ ...f, deadline: e.target.value }))} style={MODAL_IN} />
</div>
<div>
<div style={MODAL_LBL}>Requested By *</div>
<select required value={jobForm.requestedBy} onChange={e => setJobForm(f => ({ ...f, requestedBy: e.target.value }))} style={MODAL_IN}>
<option value="">Select requester</option>
{requesterOptions.map(u => <option key={u.id} value={u.id}>{u.name}</option>)}
</select>
</div>
<div>
<div style={MODAL_LBL}>Notes</div>
<textarea value={jobForm.description} onChange={e => setJobForm(f => ({ ...f, description: e.target.value }))} placeholder="Any details…" rows={3} style={{ ...MODAL_IN, resize: 'vertical' }} />
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', paddingTop: 4 }}>
<button type="submit" className="btn btn-outline" disabled={savingJob}>{savingJob ? 'Saving…' : 'Save'}</button>
<button type="button" className="btn btn-outline" onClick={() => { setShowAddJob(false); setJobForm(emptyJobForm()); }}>Cancel</button>
</div>
</form>
</div>
</div>
)}
{showClientTask && isClient && ( {showClientTask && isClient && (
<div style={popupOverlayStyle} onClick={() => { setShowClientTask(false); setClientTaskKey(k => k + 1); setClientTaskError(''); }}> <div style={popupOverlayStyle} onClick={() => { setShowClientTask(false); setClientTaskKey(k => k + 1); setClientTaskError(''); }}>
<div style={{ ...popupSurfaceStyle, width: 'min(720px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto' }} onClick={e => e.stopPropagation()}> <div style={TASK_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 14 }}>New Task</div> <div style={{ ...TASK_MODAL_TITLE_STYLE, marginBottom: 14 }}>New Task</div>
<RequestForm <RequestForm
key={clientTaskKey} key={clientTaskKey}
companies={company ? [company] : []} companies={company ? [company] : []}
@@ -626,14 +574,14 @@ export default function ProjectDetailPage() {
{addingMember && isTeam && ( {addingMember && isTeam && (
<div style={popupOverlayStyle} onClick={() => { setAddingMember(false); setSelectedExts(new Set()); }}> <div style={popupOverlayStyle} onClick={() => { setAddingMember(false); setSelectedExts(new Set()); }}>
<div style={{ ...popupSurfaceStyle, width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', display: 'flex', flexDirection: 'column', gap: 16, padding: 0 }} onClick={e => e.stopPropagation()}> <div style={{ ...TASK_MODAL_SHELL_STYLE, width: 'min(480px, 96vw)', display: 'flex', flexDirection: 'column', gap: 16, padding: 0 }} onClick={e => e.stopPropagation()}>
<div style={{ padding: '18px 21px 0' }}> <div style={{ padding: '18px 21px 0' }}>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 2 }}>Manage Subcontractors</div> <div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 2 }}>Manage Subcontractors</div>
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>{project.name}</div> <div style={{ fontSize: 13, color: 'var(--text-muted)' }}>{project.name}</div>
</div> </div>
<div style={{ flex: 1, overflowY: 'auto', padding: '0 21px' }}> <div style={{ flex: 1, overflowY: 'auto', padding: '0 21px' }}>
{externalProfiles.length === 0 {externalProfiles.length === 0
? <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 80, fontSize: 13, color: 'var(--text-muted)' }}>No subcontractors found.</div> ? <div className="card-empty-center">No subcontractors</div>
: <table style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}> : <table style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
<colgroup> <colgroup>
<col style={{ width: '5%' }} /> <col style={{ width: '5%' }} />
@@ -643,10 +591,10 @@ export default function ProjectDetailPage() {
</colgroup> </colgroup>
<thead> <thead>
<tr> <tr>
<th style={{ ...TH_STYLE, padding: '0 0 12px' }} /> <th style={{ ...TASK_TABLE_TH_STYLE, padding: '0 0 12px' }} />
<SortTh col="name" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={TH_STYLE}>Name</SortTh> <SortTh col="name" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={TASK_TABLE_TH_STYLE}>Name</SortTh>
<SortTh col="email" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={TH_STYLE}>Email</SortTh> <SortTh col="email" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={TASK_TABLE_TH_STYLE}>Email</SortTh>
<th style={{ ...TH_STYLE, padding: '0 0 12px', textAlign: 'center' }}>Assigned</th> <th style={{ ...TASK_TABLE_TH_STYLE, padding: '0 0 12px', textAlign: 'center' }}>Assigned</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -656,16 +604,10 @@ export default function ProjectDetailPage() {
return subSortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av); return subSortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
}).map(p => { }).map(p => {
const checked = selectedExts.has(p.id); const checked = selectedExts.has(p.id);
const initials = (p.name || '?').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
return ( return (
<tr key={p.id} onClick={() => setSelectedExts(prev => { const s = new Set(prev); s.has(p.id) ? s.delete(p.id) : s.add(p.id); return s; })} style={{ cursor: 'pointer' }}> <tr key={p.id} onClick={() => setSelectedExts(prev => { const s = new Set(prev); s.has(p.id) ? s.delete(p.id) : s.add(p.id); return s; })} style={{ cursor: 'pointer' }}>
<td style={{ padding: '5px 0', border: 'none', background: 'transparent' }}> <td style={{ padding: '5px 0', border: 'none', background: 'transparent' }}>
{p.avatar_url <ProfileAvatar profile={p} name={p.name} size={26} fontSize={10} />
? <div style={{ width: 26, height: 26, borderRadius: '50%', overflow: 'hidden' }}><img src={p.avatar_url} alt={p.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /></div>
: <div style={{ width: 26, height: 26, borderRadius: '50%', background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<span style={{ fontSize: 10, fontWeight: 600, color: '#000', lineHeight: 1 }}>{initials}</span>
</div>
}
</td> </td>
<td style={{ padding: '5px 5px 5px 12px', border: 'none', background: 'transparent', fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</td> <td style={{ padding: '5px 5px 5px 12px', border: 'none', background: 'transparent', fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</td>
<td style={{ padding: '5px', border: 'none', background: 'transparent', fontSize: 12, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.email || '—'}</td> <td style={{ padding: '5px', border: 'none', background: 'transparent', fontSize: 12, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.email || '—'}</td>
@@ -681,7 +623,7 @@ export default function ProjectDetailPage() {
</table> </table>
} }
</div> </div>
<div style={{ padding: '0 21px 18px', display: 'flex', gap: 8, justifyContent: 'flex-end' }}> <div className="modal-action-row" style={{ padding: '0 21px 18px' }}>
<button className="btn btn-outline" disabled={savingMembers} onClick={handleAddMember}>{savingMembers ? 'Saving…' : 'Save'}</button> <button className="btn btn-outline" disabled={savingMembers} onClick={handleAddMember}>{savingMembers ? 'Saving…' : 'Save'}</button>
<button className="btn btn-outline" onClick={() => { setAddingMember(false); setSelectedExts(new Set()); }}>Cancel</button> <button className="btn btn-outline" onClick={() => { setAddingMember(false); setSelectedExts(new Set()); }}>Cancel</button>
</div> </div>
+369 -158
View File
@@ -1,19 +1,20 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useParams, Link } from 'react-router-dom'; import { useParams, Link } from 'react-router-dom';
import Layout from '../components/Layout'; import Layout from '../components/Layout';
import PageLoader from '../components/PageLoader'; import PageLoader from '../components/PageLoader';
import FileBrowser from '../components/FileBrowser'; import ProfileAvatar from '../components/ProfileAvatar';
import StatusBadge from '../components/StatusBadge'; import StatusBadge from '../components/StatusBadge';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
import { logActivity } from '../lib/activityLog'; import { logActivity } from '../lib/activityLog';
import JSZip from 'jszip'; import JSZip from 'jszip';
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles'; import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
import { sendEmail } from '../lib/email'; import { useLiveRefresh } from '../hooks/useLiveRefresh';
import { uploadFilesToRequestInfo, safeName } from '../lib/filebrowserFolders';
import { useRefetchOnFocus } from '../hooks/useRefetchOnFocus';
import { useRealtimeSubscription } from '../hooks/useRealtimeSubscription';
import FileAttachment from '../components/FileAttachment'; import FileAttachment from '../components/FileAttachment';
import { sendTaskStatusUpdate } from '../lib/taskNotifications';
import { encodeRejectedNote, parseRejectedNote, isReviewShadowSubmission, REVIEW_SHADOW_PREFIX, getVisibleTaskSubmissions, getTaskDerivedState } from '../lib/taskVersions';
import { getCurrentVersionForTask } from '../lib/taskDeadlines';
import { getSubmissionDisplayName, mergeSubmissionDisplayNames } from '../lib/submissionDisplay';
const ACTION_LABEL = { const ACTION_LABEL = {
task_created: 'created this task', task_started: 'started this task', task_on_hold: 'placed task on hold', task_created: 'created this task', task_started: 'started this task', task_on_hold: 'placed task on hold',
@@ -63,8 +64,24 @@ const CARD = { padding: '18px 21px', borderRadius: 8 };
const LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 14 }; const LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 14 };
const META_LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 4 }; const META_LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 4 };
const CARD_META_LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 2 }; const CARD_META_LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 2 };
const MODAL_ACTION_ROW = { display: 'flex', gap: 8, justifyContent: 'flex-end', paddingTop: 4, flexWrap: 'nowrap' };
const MODAL_ACTION_BUTTON = { width: 132, justifyContent: 'center', flex: '0 0 132px' };
const TABS = ['Overview', 'Revisions', 'Submissions', 'Comments', 'Folder']; const TABS = ['Overview', 'Revisions', 'Submissions', 'Comments'];
function RejectedNoteBlock({ note }) {
if (!note?.body) return null;
const d = note.created_at ? new Date(note.created_at) : null;
const dateStr = d ? d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—';
return (
<div style={{ padding: '12px 14px', borderRadius: 8, border: '1px solid color-mix(in srgb, var(--danger) 30%, var(--border))', background: 'color-mix(in srgb, var(--danger) 8%, var(--card-bg-2))' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, marginBottom: 6 }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--danger)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Rejected Notes</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', flexShrink: 0 }}>{dateStr}</div>
</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{note.body}</div>
</div>
);
}
function TimelineCard({ task, activityLog, currentSubmission }) { function TimelineCard({ task, activityLog, currentSubmission }) {
const status = task?.status; const status = task?.status;
@@ -135,19 +152,7 @@ function TimelineCard({ task, activityLog, currentSubmission }) {
} }
function AssigneePortrait({ name, avatarUrl }) { function AssigneePortrait({ name, avatarUrl }) {
if (avatarUrl) { return <ProfileAvatar name={name} avatarUrl={avatarUrl} size={32} fontSize={12} />;
return (
<div style={{ width: 32, height: 32, borderRadius: '50%', overflow: 'hidden', flexShrink: 0 }}>
<img src={avatarUrl} alt={name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</div>
);
}
const initials = (name || '?').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
return (
<div style={{ width: 32, height: 32, borderRadius: '50%', background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: '#000', lineHeight: 1 }}>{initials}</span>
</div>
);
} }
const fmtSize = (bytes) => { const fmtSize = (bytes) => {
@@ -194,13 +199,55 @@ function SubmissionFiles({ files, downloading, onDownloadAll }) {
); );
} }
function SubmissionSigns({ signs = [], signCount = null }) {
const normalizedCount = Number(signCount || 0);
if ((!signs || signs.length === 0) && normalizedCount <= 0) return null;
return (
<div>
<div style={META_LABEL}>Signs</div>
{signs && signs.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{signs
.slice()
.sort((a, b) => Number(a.sign_number || 0) - Number(b.sign_number || 0))
.map((sign) => (
<div key={sign.id || `${sign.sign_number}-${sign.sign_name}`} style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<div style={{ fontSize: 13, color: 'var(--text-primary)', lineHeight: 1.5 }}>
<span style={{ color: 'var(--text-secondary)', marginRight: 8 }}>Sign {sign.sign_number || '—'}</span>
<span>{sign.sign_name || 'Unnamed sign'}</span>
</div>
{sign.sign_family ? (
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.4 }}>
{sign.sign_family}
</div>
) : null}
</div>
))}
</div>
) : (
<div style={{ fontSize: 13, color: 'var(--text-primary)', lineHeight: 1.5 }}>
N/A
</div>
)}
</div>
);
}
function normalizeSubmissionSigns(submission) {
return (Array.isArray(submission?.signs) ? submission.signs : [])
.map((sign, index) => ({
...sign,
sign_number: Number(sign?.sign_number || index + 1),
sign_name: String(sign?.sign_name || '').trim(),
sign_family: String(sign?.sign_family || '').trim(),
}))
.filter(sign => sign.sign_name || sign.sign_family || Number.isFinite(sign.sign_number));
}
export default function TaskDetail() { export default function TaskDetail() {
const { id } = useParams(); const { id } = useParams();
const { currentUser } = useAuth(); const { currentUser } = useAuth();
const [refreshKey, setRefreshKey] = useState(0); const { refreshKey } = useLiveRefresh(['tasks', 'submissions', 'activity_log', 'task_comments']);
const refresh = useCallback(() => setRefreshKey(k => k + 1), []);
useRefetchOnFocus(refresh);
useRealtimeSubscription(['tasks', 'submissions', 'activity_log', 'task_comments'], refresh);
const [task, setTask] = useState(null); const [task, setTask] = useState(null);
const [submissions, setSubmissions] = useState([]); const [submissions, setSubmissions] = useState([]);
const [activityLog, setActivityLog] = useState([]); const [activityLog, setActivityLog] = useState([]);
@@ -213,25 +260,55 @@ export default function TaskDetail() {
const [revisionFiles, setRevisionFiles] = useState([]); const [revisionFiles, setRevisionFiles] = useState([]);
const [revisionSaving, setRevisionSaving] = useState(false); const [revisionSaving, setRevisionSaving] = useState(false);
const revisionFileRef = useRef(null); const revisionFileRef = useRef(null);
const [rejectModal, setRejectModal] = useState(false);
const [rejectNote, setRejectNote] = useState('');
const [rejectSaving, setRejectSaving] = useState(false);
const [amendModal, setAmendModal] = useState(false); const [amendModal, setAmendModal] = useState(false);
const [amendForm, setAmendForm] = useState({ description: '' }); const [amendForm, setAmendForm] = useState({ description: '' });
const [amendFiles, setAmendFiles] = useState([]); const [amendFiles, setAmendFiles] = useState([]);
const [amendSaving, setAmendSaving] = useState(false); const [amendSaving, setAmendSaving] = useState(false);
const [amendDragging, setAmendDragging] = useState(false);
const amendDragCounter = useRef(0);
const amendFileRef = useRef(null);
const [reviewModal, setReviewModal] = useState(false); const [reviewModal, setReviewModal] = useState(false);
const [reviewFiles, setReviewFiles] = useState([]); const [reviewFiles, setReviewFiles] = useState([]);
const [reviewNotes, setReviewNotes] = useState(''); const [reviewNotes, setReviewNotes] = useState('');
const [reviewSaving, setReviewSaving] = useState(false); const [reviewSaving, setReviewSaving] = useState(false);
const [reviewDragging, setReviewDragging] = useState(false);
const reviewDragCounter = useRef(0);
const reviewFileRef = useRef(null);
const [comments, setComments] = useState([]); const [comments, setComments] = useState([]);
const [commentBody, setCommentBody] = useState(''); const [commentBody, setCommentBody] = useState('');
const [commentSaving, setCommentSaving] = useState(false); const [commentSaving, setCommentSaving] = useState(false);
const [deliveries, setDeliveries] = useState([]); const [deliveries, setDeliveries] = useState([]);
const [dlProgress, setDlProgress] = useState({ active: false, label: '', percent: 0 }); const [dlProgress, setDlProgress] = useState({ active: false, label: '', percent: 0 });
const canonicalTaskServiceType = getTaskDerivedState(task, submissions, deliveries).serviceType;
const fetchTaskSubmissions = async (taskId) => {
const { data: subRows } = await supabase
.from('submissions')
.select('id, type, version_number, request_key, submitted_by, submitted_by_name, submitted_at, service_type, deadline, description, is_hot, sign_family, sign_count, signs, files:submission_files(id, name, storage_path, size)')
.eq('task_id', taskId)
.order('submitted_at', { ascending: true });
const submitterIds = [...new Set((subRows || []).map((submission) => submission.submitted_by).filter(Boolean))];
if (submitterIds.length === 0) {
return (subRows || []).map(submissionRow => ({ ...submissionRow, signs: normalizeSubmissionSigns(submissionRow) }));
}
const { data: submitterProfiles } = await supabase
.from('profiles')
.select('id, name')
.in('id', submitterIds);
return mergeSubmissionDisplayNames(
(subRows || []).map(submissionRow => ({ ...submissionRow, signs: normalizeSubmissionSigns(submissionRow) })),
submitterProfiles || []
);
};
const reloadTaskRow = async () => {
const { data } = await supabase
.from('tasks')
.select('*, project:projects(id, name, company:companies(id, name)), assignee:profiles!assigned_to(id, name, avatar_url)')
.eq('id', id)
.single();
if (data) setTask(data);
};
useEffect(() => { useEffect(() => {
Promise.all([ Promise.all([
@@ -240,11 +317,7 @@ export default function TaskDetail() {
.select('*, project:projects(id, name, company:companies(id, name)), assignee:profiles!assigned_to(id, name, avatar_url)') .select('*, project:projects(id, name, company:companies(id, name)), assignee:profiles!assigned_to(id, name, avatar_url)')
.eq('id', id) .eq('id', id)
.single(), .single(),
supabase fetchTaskSubmissions(id),
.from('submissions')
.select('id, type, version_number, request_key, submitted_by, submitted_by_name, submitted_at, service_type, deadline, description, is_hot, sign_family, sign_count, files:submission_files(id, name, storage_path, size)')
.eq('task_id', id)
.order('submitted_at', { ascending: true }),
supabase supabase
.from('activity_log') .from('activity_log')
.select('id, created_at, actor_id, actor_name, action, task_id, task_title') .select('id, created_at, actor_id, actor_name, action, task_id, task_title')
@@ -256,7 +329,7 @@ export default function TaskDetail() {
.select('id, author_id, author_name, body, created_at') .select('id, author_id, author_name, body, created_at')
.eq('task_id', id) .eq('task_id', id)
.order('created_at', { ascending: true }), .order('created_at', { ascending: true }),
]).then(async ([{ data: taskData }, { data: subRows }, { data: actData }, { data: commentData }]) => { ]).then(async ([{ data: taskData }, subRows, { data: actData }, { data: commentData }]) => {
setTask(taskData); setTask(taskData);
setSubmissions(subRows || []); setSubmissions(subRows || []);
setActivityLog(actData || []); setActivityLog(actData || []);
@@ -277,76 +350,225 @@ export default function TaskDetail() {
if (loading) return <Layout><PageLoader /></Layout>; if (loading) return <Layout><PageLoader /></Layout>;
if (!task) return <Layout><p style={{ padding: 24 }}>Task not found.</p></Layout>; if (!task) return <Layout><p style={{ padding: 24 }}>Task not found.</p></Layout>;
const submission = submissions.find(s => s.type === 'initial') || submissions[0] || null; const visibleSubmissions = getVisibleTaskSubmissions(submissions);
const amendments = submissions.filter(s => s.type === 'amendment'); const submission = visibleSubmissions.find(s => s.type === 'initial') || visibleSubmissions[0] || null;
const revisions = submissions.filter(s => s.type === 'revision').sort((a, b) => a.version_number - b.version_number); const rejectedNoteSubmissions = visibleSubmissions
.filter(s => s.type === 'amendment' && parseRejectedNote(s.description))
.map(s => ({ ...s, rejected: parseRejectedNote(s.description) }));
const amendments = visibleSubmissions.filter(s => s.type === 'amendment' && !parseRejectedNote(s.description));
const revisions = visibleSubmissions.filter(s => s.type === 'revision').sort((a, b) => a.version_number - b.version_number);
const reviewSubmissions = deliveries; const reviewSubmissions = deliveries;
const commentRows = comments;
const rejectedNoteByVersion = new Map(
rejectedNoteSubmissions.map(note => [note.rejected.versionNumber, { ...note, body: note.rejected.body, created_at: note.submitted_at }])
);
const displayCurrentVersion = getCurrentVersionForTask(task, submissions);
const assignee = task.assignee; const assignee = task.assignee;
const assigneeName = assignee?.name || task.assigned_name || null; const assigneeName = assignee?.name || task.assigned_name || null;
const assigneeAvatar = assignee?.avatar_url || null; const assigneeAvatar = assignee?.avatar_url || null;
const project = task.project; const project = task.project;
const company = project?.company; const company = project?.company;
const requester = submission?.submitted_by_name || null; const requester = getSubmissionDisplayName(submission) || null;
const role = currentUser?.role; const role = currentUser?.role;
const isClient = role === 'client'; const isClient = role === 'client';
const isTeamOrSub = role === 'team' || role === 'external'; const isTeamOrSub = role === 'team' || role === 'external';
const status = task.status; const status = task.status;
const notificationBase = {
taskId: id,
taskTitle: task?.title || '',
projectId: project?.id || '',
projectName: project?.name || '',
companyId: company?.id || '',
companyName: company?.name || '',
assignedProfileId: task?.assigned_to || currentUser?.id || '',
};
const actionBusy = statusSaving || reviewSaving || revisionSaving || rejectSaving || amendSaving;
const log = (action) => logActivity({ actorId: currentUser.id, actorName: currentUser.name, action, taskId: id, taskTitle: task?.title, projectId: task?.project?.id, projectName: task?.project?.name }); const log = (action) => logActivity({ actorId: currentUser.id, actorName: currentUser.name, action, taskId: id, taskTitle: task?.title, projectId: task?.project?.id, projectName: task?.project?.name });
const updateStatus = async (newStatus, extra = {}, action = null) => { const updateStatus = async (newStatus, extra = {}, action = null) => {
setStatusSaving(true); setStatusSaving(true);
await supabase.from('tasks').update({ status: newStatus, ...extra }).eq('id', id); const prevTask = task;
setTask(t => ({ ...t, status: newStatus, ...extra })); setTask(t => ({ ...t, status: newStatus, ...extra }));
try {
const { error: taskUpdateError } = await supabase.from('tasks').update({ status: newStatus, ...extra }).eq('id', id);
if (taskUpdateError) throw taskUpdateError;
await reloadTaskRow();
if (action) { if (action) {
await log(action); await log(action);
const { data } = await supabase.from('activity_log').select('id, created_at, actor_id, actor_name, action').eq('task_id', id).order('created_at', { ascending: false }).limit(20); const { data } = await supabase.from('activity_log').select('id, created_at, actor_id, actor_name, action').eq('task_id', id).order('created_at', { ascending: false }).limit(20);
setActivityLog(data || []); setActivityLog(data || []);
} }
} catch (err) {
setTask(prevTask);
console.error('Status update failed:', err);
window.alert(err?.message || 'Failed to update status.');
throw err;
} finally {
setStatusSaving(false); setStatusSaving(false);
}
}; };
const handleStart = () => updateStatus('in_progress', { assigned_to: currentUser.id, assigned_name: currentUser.name }, 'task_started'); const handleStart = () => updateStatus('in_progress', { assigned_to: currentUser.id, assigned_name: currentUser.name }, 'task_started');
const handleOnHold = () => updateStatus('on_hold', {}, 'task_on_hold'); const handleOnHold = async () => {
await updateStatus('on_hold', {}, 'task_on_hold');
sendTaskStatusUpdate({
...notificationBase,
statusLabel: 'On Hold',
headline: 'Task On Hold',
subject: `Task On Hold: ${task?.title || 'Task'}`,
message: `${currentUser.name} placed ${task?.title || 'this task'} on hold${project?.name ? ` for ${project.name}` : ''}.`,
includeTeam: true,
includeAssigned: true,
includeClient: true,
}).catch(() => {});
};
const handleResume = () => updateStatus('in_progress', {}, 'task_resumed'); const handleResume = () => updateStatus('in_progress', {}, 'task_resumed');
const handleSendToReview = () => { setReviewFiles([]); setReviewNotes(''); setReviewModal(true); }; const handleSendToReview = () => { setReviewFiles([]); setReviewNotes(''); setReviewModal(true); };
const handleRemoveFromReview = () => updateStatus('in_progress', {}, 'task_resumed'); const handleRemoveFromReview = () => updateStatus('in_progress', {}, 'task_resumed');
const handleConfirmReview = async () => { const handleConfirmReview = async () => {
setReviewSaving(true); setReviewSaving(true);
try { try {
const briefId = submissions.find(s => s.type === 'initial')?.id || submissions[0]?.id; const targetVersion = task.current_version || 0;
const { data: newDel } = await supabase.from('deliveries').insert({ submission_id: briefId, sent_by: currentUser.name, message: reviewNotes.trim() || '', version_number: task.current_version || 0 }).select().single(); const versionSubmissions = submissions.filter(s => (s.version_number || 0) === targetVersion && s.type !== 'amendment');
const usedSubmissionIds = new Set(
deliveries
.filter(d => (d.version_number || 0) === targetVersion)
.filter(d => versionSubmissions.some(s => s.id === d.submission_id))
.map(d => d.submission_id)
);
let targetSubmission = versionSubmissions.find(s => !usedSubmissionIds.has(s.id)) || null;
if (!targetSubmission) {
const { data: createdSubmission, error: createdSubmissionError } = await supabase
.from('submissions')
.insert({
task_id: id,
version_number: targetVersion,
type: targetVersion === 0 ? 'initial' : 'revision',
revision_type: targetVersion > 0 ? 'client_revision' : null,
service_type: canonicalTaskServiceType !== '—' ? canonicalTaskServiceType : null,
description: `${REVIEW_SHADOW_PREFIX}${targetVersion}`,
submitted_by: currentUser.id,
submitted_by_name: currentUser.name,
})
.select('id, type, version_number, request_key, submitted_by, submitted_by_name, submitted_at, service_type, deadline, description, is_hot, sign_family, sign_count, signs')
.single();
if (createdSubmissionError) throw createdSubmissionError;
targetSubmission = createdSubmission || null;
}
if (!targetSubmission?.id) throw new Error('No submission found for current version.');
const { data: newDel, error: deliveryError } = await supabase
.from('deliveries')
.insert({ submission_id: targetSubmission.id, sent_by: currentUser.name, message: reviewNotes.trim() || '', version_number: targetVersion })
.select()
.single();
if (deliveryError) throw deliveryError;
if (!newDel?.id) throw new Error('Failed to create review submission.');
if (newDel && reviewFiles.length > 0) { if (newDel && reviewFiles.length > 0) {
const uploaded = []; const uploaded = [];
for (const file of reviewFiles) { for (const file of reviewFiles) {
const path = `${id}/${Date.now()}_${file.name}`; const path = `${id}/${Date.now()}_${file.name}`;
const { data: up } = await supabase.storage.from('deliveries').upload(path, file); const { data: up, error: uploadError } = await supabase.storage.from('deliveries').upload(path, file);
if (uploadError) throw uploadError;
if (up) uploaded.push({ name: file.name, storage_path: path, size: file.size, delivery_id: newDel.id }); if (up) uploaded.push({ name: file.name, storage_path: path, size: file.size, delivery_id: newDel.id });
} }
if (uploaded.length > 0) await supabase.from('delivery_files').insert(uploaded); if (uploaded.length > 0) {
const { error: deliveryFilesError } = await supabase.from('delivery_files').insert(uploaded);
if (deliveryFilesError) throw deliveryFilesError;
} }
const subIds = submissions.map(s => s.id); }
const refreshedSubRows = await fetchTaskSubmissions(id);
const allSubRows = refreshedSubRows || [];
setSubmissions(allSubRows);
const subIds = allSubRows.map(s => s.id);
const { data: delRows } = await supabase.from('deliveries').select('*, files:delivery_files(id, name, storage_path, size)').in('submission_id', subIds).order('sent_at', { ascending: false }); const { data: delRows } = await supabase.from('deliveries').select('*, files:delivery_files(id, name, storage_path, size)').in('submission_id', subIds).order('sent_at', { ascending: false });
setDeliveries(delRows || []); setDeliveries(delRows || []);
await updateStatus('client_review', {}, 'task_submitted'); await updateStatus('client_review', {}, 'task_submitted');
sendTaskStatusUpdate({
...notificationBase,
statusLabel: 'In Review',
headline: 'Task Ready For Review',
subject: `In Review: ${task?.title || 'Task'}`,
message: `${currentUser.name} placed ${task?.title || 'this task'} in review${project?.name ? ` for ${project.name}` : ''}.${reviewNotes.trim() ? ` Notes: ${reviewNotes.trim()}` : ''}`,
includeTeam: true,
includeAssigned: true,
includeClient: true,
}).catch(() => {});
setReviewModal(false); setReviewModal(false);
setReviewFiles([]); setReviewFiles([]);
setReviewNotes(''); setReviewNotes('');
} catch (err) {
console.error('Place in review failed:', err);
window.alert(err?.message || 'Failed to place task in review.');
} finally { } finally {
setReviewSaving(false); setReviewSaving(false);
} }
}; };
const handleClientApprove = () => updateStatus('client_approved', { completed_at: new Date().toISOString() }, 'task_approved'); const handleClientApprove = async () => {
const handleClientReject = () => updateStatus('not_started', { current_version: (task.current_version || 0) + 1, assigned_to: null, assigned_name: null }, 'revision_requested'); await updateStatus('client_approved', { completed_at: new Date().toISOString() }, 'task_approved');
sendTaskStatusUpdate({
...notificationBase,
statusLabel: 'Approved',
headline: 'Task Approved',
subject: `Approved: ${task?.title || 'Task'}`,
message: `${currentUser.name} approved ${task?.title || 'this task'}${project?.name ? ` for ${project.name}` : ''}.`,
includeTeam: true,
includeAssigned: true,
includeClient: true,
}).catch(() => {});
};
const handleClientReject = () => { setRejectNote(''); setRejectModal(true); };
const handleSubmitReject = async () => {
if (!rejectNote.trim()) return;
setRejectSaving(true);
try {
const rejectedVersion = task.current_version || 0;
const { data: rejectedNoteSubmission } = await supabase
.from('submissions')
.insert({
task_id: id,
version_number: rejectedVersion,
type: 'amendment',
service_type: canonicalTaskServiceType !== '—' ? canonicalTaskServiceType : null,
description: encodeRejectedNote(rejectedVersion, rejectNote.trim()),
submitted_by: currentUser.id,
submitted_by_name: currentUser.name,
})
.select()
.single();
if (rejectedNoteSubmission) setSubmissions(prev => [...prev, rejectedNoteSubmission]);
await updateStatus('in_progress', {}, 'revision_requested');
sendTaskStatusUpdate({
...notificationBase,
statusLabel: 'Rejected',
headline: 'Task Rejected',
subject: `Rejected: ${task?.title || 'Task'}`,
message: `${currentUser.name} rejected ${task?.title || 'this task'}${project?.name ? ` for ${project.name}` : ''}. Notes: ${rejectNote.trim()}`,
includeTeam: true,
includeAssigned: true,
includeClient: true,
}).catch(() => {});
setRejectModal(false);
setRejectNote('');
} finally {
setRejectSaving(false);
}
};
const handleRelease = () => updateStatus('not_started', { assigned_to: null, assigned_name: null }, 'task_released'); const handleRelease = () => updateStatus('not_started', { assigned_to: null, assigned_name: null }, 'task_released');
const handleSubmitRevision = async () => { const handleSubmitRevision = async () => {
setRevisionSaving(true); setRevisionSaving(true);
const baseline = Math.max(task.current_version || 0, ...submissions.map(s => s.version_number || 0)); const baseline = Math.max(task.current_version || 0, ...submissions.map(s => s.version_number || 0));
const newVersion = baseline + 1; const newVersion = baseline + 1;
await supabase.from('tasks').update({ status: 'not_started', current_version: newVersion, assigned_to: null, assigned_name: null }).eq('id', id); await supabase.from('tasks').update({
const { data: newSub } = await supabase.from('submissions').insert({ task_id: id, version_number: newVersion, type: 'revision', revision_type: 'client_revision', service_type: task.title, deadline: revisionForm.deadline || null, description: revisionForm.description, submitted_by: currentUser.id, submitted_by_name: currentUser.name }).select().single(); status: 'not_started',
current_version: newVersion,
assigned_to: task?.assigned_to || null,
assigned_name: task?.assigned_name || task?.assignee?.name || null,
}).eq('id', id);
const { data: newSub } = await supabase.from('submissions').insert({ task_id: id, version_number: newVersion, type: 'revision', revision_type: 'client_revision', service_type: canonicalTaskServiceType !== '—' ? canonicalTaskServiceType : null, deadline: revisionForm.deadline || null, description: revisionForm.description, submitted_by: currentUser.id, submitted_by_name: currentUser.name }).select().single();
if (newSub && revisionFiles.length > 0) { if (newSub && revisionFiles.length > 0) {
const uploadedFiles = []; const uploadedFiles = [];
for (const file of revisionFiles) { for (const file of revisionFiles) {
@@ -355,18 +577,27 @@ export default function TaskDetail() {
if (uploaded) uploadedFiles.push({ name: file.name, storage_path: path, size: file.size }); if (uploaded) uploadedFiles.push({ name: file.name, storage_path: path, size: file.size });
} }
if (uploadedFiles.length > 0) await supabase.from('submission_files').insert(uploadedFiles.map(f => ({ ...f, submission_id: newSub.id }))); if (uploadedFiles.length > 0) await supabase.from('submission_files').insert(uploadedFiles.map(f => ({ ...f, submission_id: newSub.id })));
if (project?.name && task?.title) uploadFilesToRequestInfo(revisionFiles, company?.name, project.name, task.title, newVersion).catch(() => {});
} }
await logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'revision_requested', taskId: id, taskTitle: task?.title, projectId: project?.id, projectName: project?.name }); await logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'revision_requested', taskId: id, taskTitle: task?.title, projectId: project?.id, projectName: project?.name });
sendEmail('revision_submitted', ['hello@fourgebranding.com'], { clientName: currentUser.name, serviceType: task.title, projectName: project?.name, version: `R${String(newVersion).padStart(2, '0')}`, deadline: revisionForm.deadline, description: revisionForm.description, taskId: id }).catch(() => {}); const [{ data: taskData }, subRows, { data: actData }] = await Promise.all([
const [{ data: taskData }, { data: subRows }, { data: actData }] = await Promise.all([
supabase.from('tasks').select('*, project:projects(id, name, company:companies(id, name)), assignee:profiles!assigned_to(id, name, avatar_url)').eq('id', id).single(), supabase.from('tasks').select('*, project:projects(id, name, company:companies(id, name)), assignee:profiles!assigned_to(id, name, avatar_url)').eq('id', id).single(),
supabase.from('submissions').select('id, type, version_number, submitted_by, submitted_by_name, submitted_at, service_type, deadline, description, is_hot, sign_family, sign_count, files:submission_files(id, name, storage_path, size)').eq('task_id', id).order('submitted_at', { ascending: true }), fetchTaskSubmissions(id),
supabase.from('activity_log').select('id, created_at, actor_id, actor_name, action, task_id, task_title').eq('task_id', id).order('created_at', { ascending: false }).limit(20), supabase.from('activity_log').select('id, created_at, actor_id, actor_name, action, task_id, task_title').eq('task_id', id).order('created_at', { ascending: false }).limit(20),
]); ]);
setTask(taskData); setTask(taskData);
setSubmissions(subRows || []); setSubmissions(subRows || []);
setActivityLog(actData || []); setActivityLog(actData || []);
sendTaskStatusUpdate({
...notificationBase,
assignedProfileId: task?.assigned_to || '',
statusLabel: `Revision Request ${`R${String(newVersion).padStart(2, '0')}`}`,
headline: 'Revision Requested',
subject: `Revision Request: ${task?.title || 'Task'} ${`R${String(newVersion).padStart(2, '0')}`}`,
message: `${currentUser.name} requested a revision for ${task?.title || 'this task'}${project?.name ? ` on ${project.name}` : ''}. ${revisionForm.description}`,
includeTeam: true,
includeAssigned: true,
includeClient: true,
}).catch(() => {});
setRevisionModal(false); setRevisionModal(false);
setRevisionForm({ description: '', deadline: '' }); setRevisionForm({ description: '', deadline: '' });
setRevisionFiles([]); setRevisionFiles([]);
@@ -378,7 +609,7 @@ export default function TaskDetail() {
setAmendSaving(true); setAmendSaving(true);
setAmendModal(false); setAmendModal(false);
const baseline = Math.max(task.current_version || 0, ...submissions.map(s => s.version_number || 0)); const baseline = Math.max(task.current_version || 0, ...submissions.map(s => s.version_number || 0));
const { data: newSub } = await supabase.from('submissions').insert({ task_id: id, version_number: baseline, type: 'amendment', service_type: task.title, description: amendForm.description, submitted_by: currentUser.id, submitted_by_name: currentUser.name }).select().single(); const { data: newSub } = await supabase.from('submissions').insert({ task_id: id, version_number: baseline, type: 'amendment', service_type: canonicalTaskServiceType !== '—' ? canonicalTaskServiceType : null, description: amendForm.description, submitted_by: currentUser.id, submitted_by_name: currentUser.name }).select().single();
if (newSub && amendFiles.length > 0) { if (newSub && amendFiles.length > 0) {
setDlProgress({ active: true, label: 'Uploading files…', percent: 0 }); setDlProgress({ active: true, label: 'Uploading files…', percent: 0 });
const uploaded = []; const uploaded = [];
@@ -390,11 +621,20 @@ export default function TaskDetail() {
if (up) uploaded.push({ name: file.name, storage_path: path, size: file.size }); if (up) uploaded.push({ name: file.name, storage_path: path, size: file.size });
} }
if (uploaded.length > 0) await supabase.from('submission_files').insert(uploaded.map(f => ({ ...f, submission_id: newSub.id }))); if (uploaded.length > 0) await supabase.from('submission_files').insert(uploaded.map(f => ({ ...f, submission_id: newSub.id })));
if (project?.name && task?.title) uploadFilesToRequestInfo(amendFiles, company?.name, project.name, task.title, baseline).catch(() => {});
setDlProgress({ active: false, label: '', percent: 0 }); setDlProgress({ active: false, label: '', percent: 0 });
} }
const { data: subRows } = await supabase.from('submissions').select('id, type, version_number, submitted_by, submitted_by_name, submitted_at, service_type, deadline, description, is_hot, sign_family, sign_count, files:submission_files(id, name, storage_path, size)').eq('task_id', id).order('submitted_at', { ascending: true }); const subRows = await fetchTaskSubmissions(id);
setSubmissions(subRows || []); setSubmissions(subRows || []);
sendTaskStatusUpdate({
...notificationBase,
statusLabel: 'Request Amended',
headline: 'Request Amended',
subject: `Request Amended: ${task?.title || 'Task'}`,
message: `${currentUser.name} amended ${task?.title || 'this task'}${project?.name ? ` for ${project.name}` : ''}. Notes: ${amendForm.description.trim()}`,
includeTeam: true,
includeAssigned: true,
includeClient: true,
}).catch(() => {});
setAmendForm({ description: '' }); setAmendForm({ description: '' });
setAmendFiles([]); setAmendFiles([]);
setAmendSaving(false); setAmendSaving(false);
@@ -454,38 +694,38 @@ export default function TaskDetail() {
<div style={{ fontSize: 28, fontWeight: 500, color: 'var(--text-primary)', lineHeight: 1.2 }}>{task.title}</div> <div style={{ fontSize: 28, fontWeight: 500, color: 'var(--text-primary)', lineHeight: 1.2 }}>{task.title}</div>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}> <div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
{isTeamOrSub && status === 'not_started' && ( {isTeamOrSub && status === 'not_started' && (
<button className="btn btn-outline" disabled={statusSaving} onClick={handleStart}>Start Task</button> <button className="btn btn-outline" disabled={actionBusy} onClick={handleStart}>Start Task</button>
)} )}
{isTeamOrSub && status === 'in_progress' && task.assigned_to === currentUser?.id && ( {isTeamOrSub && status === 'in_progress' && task.assigned_to === currentUser?.id && (
<> <>
<button className="btn btn-outline" disabled={statusSaving} onClick={handleRelease}>Release</button> <button className="btn btn-outline" disabled={actionBusy} onClick={handleRelease}>Release</button>
<button className="btn btn-outline" disabled={statusSaving} onClick={handleOnHold}>Place on Hold</button> <button className="btn btn-outline" disabled={actionBusy} onClick={handleOnHold}>Place on Hold</button>
<button className="btn btn-primary" disabled={statusSaving} onClick={handleSendToReview}>Place in Review</button> <button className="btn btn-primary" disabled={actionBusy} onClick={handleSendToReview}>Place in Review</button>
</> </>
)} )}
{isTeamOrSub && status === 'client_review' && task.assigned_to === currentUser?.id && ( {isTeamOrSub && status === 'client_review' && task.assigned_to === currentUser?.id && (
<button className="btn btn-outline" disabled={statusSaving} onClick={handleRemoveFromReview}>Remove from Review</button> <button className="btn btn-outline" disabled={actionBusy} onClick={handleRemoveFromReview}>Remove from Review</button>
)} )}
{isTeamOrSub && status === 'on_hold' && task.assigned_to === currentUser?.id && ( {isTeamOrSub && status === 'on_hold' && task.assigned_to === currentUser?.id && (
<> <>
<button className="btn btn-outline" disabled={statusSaving} onClick={handleRelease}>Release</button> <button className="btn btn-outline" disabled={actionBusy} onClick={handleRelease}>Release</button>
<button className="btn btn-outline" disabled={statusSaving} onClick={handleResume}>Resume</button> <button className="btn btn-outline" disabled={actionBusy} onClick={handleResume}>Resume</button>
</> </>
)} )}
{isClient && status === 'client_review' && ( {isClient && status === 'client_review' && (
<> <>
<button className="btn btn-primary" disabled={statusSaving} onClick={handleClientApprove}>Approve</button> <button className="btn btn-primary" disabled={actionBusy} onClick={handleClientApprove}>Approve</button>
<button className="btn btn-danger" disabled={statusSaving} onClick={handleClientReject}>Reject</button> <button className="btn btn-danger" disabled={actionBusy} onClick={handleClientReject}>Reject</button>
</> </>
)} )}
{isClient && ['client_approved', 'invoiced', 'paid'].includes(status) && ( {isClient && ['client_approved', 'invoiced', 'paid'].includes(status) && (
<button className="btn btn-outline" onClick={() => setRevisionModal(true)}>Request Revision</button> <button className="btn btn-outline" disabled={actionBusy} onClick={() => setRevisionModal(true)}>Request Revision</button>
)} )}
</div> </div>
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 20 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 20 }}>
{submission?.service_type && ( {canonicalTaskServiceType !== '—' && (
<span className="badge badge-status badge-client">{submission.service_type}</span> <span className="badge badge-status badge-client">{canonicalTaskServiceType}</span>
)} )}
<StatusBadge status={task.status || 'not_started'} /> <StatusBadge status={task.status || 'not_started'} />
</div> </div>
@@ -501,7 +741,7 @@ export default function TaskDetail() {
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><polyline points="17,1 21,5 17,9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7,23 3,19 7,15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><polyline points="17,1 21,5 17,9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7,23 3,19 7,15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
<div> <div>
<div style={CARD_META_LABEL}>Revision</div> <div style={CARD_META_LABEL}>Revision</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>{(task.current_version ?? 0) === 0 ? 'New' : String(task.current_version).padStart(2, '0')}</div> <div style={{ fontSize: 13, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>{displayCurrentVersion === 0 ? 'New' : String(displayCurrentVersion).padStart(2, '0')}</div>
</div> </div>
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
@@ -541,7 +781,7 @@ export default function TaskDetail() {
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}> <div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10 }}>
{TABS.map(tab => { {TABS.map(tab => {
const count = tab === 'Revisions' ? revisions.length : tab === 'Submissions' ? reviewSubmissions.length : tab === 'Comments' ? comments.length : 0; const count = tab === 'Revisions' ? revisions.length : tab === 'Submissions' ? reviewSubmissions.length : tab === 'Comments' ? commentRows.length : 0;
return ( return (
<button key={tab} onClick={() => setActiveTab(tab)} className={`section-tab-btn${activeTab === tab ? ' is-active' : ''}`}> <button key={tab} onClick={() => setActiveTab(tab)} className={`section-tab-btn${activeTab === tab ? ' is-active' : ''}`}>
{tab}{count > 0 && <span style={{ marginLeft: 5, fontSize: 12, fontWeight: 600, color: activeTab === tab ? 'var(--accent)' : 'var(--text-muted)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 10, padding: '3px 8px' }}>{count}</span>} {tab}{count > 0 && <span style={{ marginLeft: 5, fontSize: 12, fontWeight: 600, color: activeTab === tab ? 'var(--accent)' : 'var(--text-muted)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 10, padding: '3px 8px' }}>{count}</span>}
@@ -549,8 +789,8 @@ export default function TaskDetail() {
); );
})} })}
</div> </div>
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, overflowY: activeTab === 'Folder' ? 'hidden' : 'auto', padding: activeTab === 'Folder' ? 0 : CARD.padding, ...(activeTab === 'Folder' ? { display: 'flex', flexDirection: 'column' } : {}) }}> <div className="card" style={{ ...CARD, flex: 1, minHeight: 0, overflowY: 'auto' }}>
<div style={{ fontSize: 13, ...(activeTab === 'Folder' ? { flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 } : {}) }}> <div style={{ fontSize: 13 }}>
{activeTab === 'Overview' && ( {activeTab === 'Overview' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 32 }}> <div style={{ display: 'flex', alignItems: 'flex-start', gap: 32 }}>
@@ -573,6 +813,11 @@ export default function TaskDetail() {
<button className="btn btn-outline" onClick={() => setAmendModal(true)}>Amend Request</button> <button className="btn btn-outline" onClick={() => setAmendModal(true)}>Amend Request</button>
</div> </div>
)} )}
{rejectedNoteByVersion.get(0) && (
<div style={{ marginLeft: 'auto', flex: '0 0 min(320px, 36%)' }}>
<RejectedNoteBlock note={rejectedNoteByVersion.get(0)} />
</div>
)}
</div> </div>
{submission?.description && ( {submission?.description && (
<div> <div>
@@ -586,6 +831,7 @@ export default function TaskDetail() {
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{submission.sign_family}</div> <div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{submission.sign_family}</div>
</div> </div>
)} )}
<SubmissionSigns signs={submission?.signs} signCount={submission?.sign_count} />
<SubmissionFiles files={submission?.files} downloading={downloading} onDownloadAll={downloadAllFiles} /> <SubmissionFiles files={submission?.files} downloading={downloading} onDownloadAll={downloadAllFiles} />
{amendments.length > 0 && ( {amendments.length > 0 && (
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 20, display: 'flex', flexDirection: 'column', gap: 20 }}> <div style={{ borderTop: '1px solid var(--border)', paddingTop: 20, display: 'flex', flexDirection: 'column', gap: 20 }}>
@@ -609,7 +855,7 @@ export default function TaskDetail() {
)} )}
{activeTab === 'Revisions' && ( {activeTab === 'Revisions' && (
revisions.length === 0 revisions.length === 0
? <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 1, minHeight: 120, color: 'var(--text-muted)' }}>No revisions yet.</div> ? <div className="card-empty-center">No revisions</div>
: <div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}> : <div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
{[...revisions].reverse().map((rev, i, arr) => { {[...revisions].reverse().map((rev, i, arr) => {
const rNum = `R${String(rev.version_number).padStart(2, '0')}`; const rNum = `R${String(rev.version_number).padStart(2, '0')}`;
@@ -621,7 +867,7 @@ export default function TaskDetail() {
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 32 }}> <div style={{ display: 'flex', alignItems: 'flex-start', gap: 32 }}>
<div> <div>
<div style={META_LABEL}>Requested By</div> <div style={META_LABEL}>Requested By</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{rev.submitted_by_name || '—'}</div> <div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{getSubmissionDisplayName(rev) || '—'}</div>
</div> </div>
<div> <div>
<div style={META_LABEL}>Requested</div> <div style={META_LABEL}>Requested</div>
@@ -640,6 +886,11 @@ export default function TaskDetail() {
<button className="btn btn-outline" onClick={() => setAmendModal(true)}>Amend</button> <button className="btn btn-outline" onClick={() => setAmendModal(true)}>Amend</button>
</div> </div>
)} )}
{rejectedNoteByVersion.get(rev.version_number) && (
<div style={{ marginLeft: 'auto', flex: '0 0 min(320px, 36%)' }}>
<RejectedNoteBlock note={rejectedNoteByVersion.get(rev.version_number)} />
</div>
)}
</div> </div>
{rev.description && ( {rev.description && (
<div> <div>
@@ -653,7 +904,8 @@ export default function TaskDetail() {
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{rev.sign_family}</div> <div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{rev.sign_family}</div>
</div> </div>
)} )}
{!rev.description && !rev.sign_family && ( <SubmissionSigns signs={rev.signs} signCount={rev.sign_count} />
{!rev.description && !rev.sign_family && !(rev.signs?.length > 0) && !(Number(rev.sign_count || 0) > 0) && (
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No notes provided.</div> <div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No notes provided.</div>
)} )}
<SubmissionFiles files={rev.files} downloading={downloading} onDownloadAll={(f) => downloadAllFiles(f, rNum)} /> <SubmissionFiles files={rev.files} downloading={downloading} onDownloadAll={(f) => downloadAllFiles(f, rNum)} />
@@ -664,7 +916,7 @@ export default function TaskDetail() {
)} )}
{activeTab === 'Submissions' && ( {activeTab === 'Submissions' && (
reviewSubmissions.length === 0 reviewSubmissions.length === 0
? <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 1, minHeight: 120, color: 'var(--text-muted)' }}>No submissions yet.</div> ? <div className="card-empty-center">No submissions</div>
: <div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}> : <div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
{reviewSubmissions.map((sub, i, arr) => { {reviewSubmissions.map((sub, i, arr) => {
const fmtDate = sub.sent_at const fmtDate = sub.sent_at
@@ -681,7 +933,7 @@ export default function TaskDetail() {
<div style={META_LABEL}>Date</div> <div style={META_LABEL}>Date</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{fmtDate}</div> <div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{fmtDate}</div>
</div> </div>
<div style={{ marginLeft: 'auto' }}> <div>
<div style={META_LABEL}>Version</div> <div style={META_LABEL}>Version</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)', fontWeight: 500 }}>{`R${String(sub.version_number ?? 0).padStart(2, '0')}`}</div> <div style={{ fontSize: 13, color: 'var(--text-primary)', fontWeight: 500 }}>{`R${String(sub.version_number ?? 0).padStart(2, '0')}`}</div>
</div> </div>
@@ -713,8 +965,8 @@ export default function TaskDetail() {
<button className="btn btn-outline" disabled={commentSaving || !commentBody.trim()} onClick={handlePostComment}>Post</button> <button className="btn btn-outline" disabled={commentSaving || !commentBody.trim()} onClick={handlePostComment}>Post</button>
</div> </div>
{comments.length === 0 {comments.length === 0
? <div style={{ color: 'var(--text-muted)', fontSize: 13 }}>No comments yet.</div> ? <div className="card-empty-center">No comments</div>
: comments.map((c, i) => { : commentRows.map((c, i) => {
const d = new Date(c.created_at); const d = new Date(c.created_at);
const dateStr = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); const dateStr = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
const timeStr = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); const timeStr = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
@@ -737,22 +989,6 @@ export default function TaskDetail() {
} }
</div> </div>
)} )}
{activeTab === 'Folder' && company?.name && project?.name && (() => {
const fbInitial = role === 'external'
? `/Projects/${safeName(project.name)}/${safeName(task.title)}`
: role === 'client'
? `/${safeName(company.name)}/Projects/${safeName(project.name)}/${safeName(task.title)}`
: `/Clients/${safeName(company.name)}/Projects/${safeName(project.name)}/${safeName(task.title)}`;
const fbRoot = role === 'external'
? `/Projects/${safeName(project.name)}`
: role === 'client'
? `/${safeName(company.name)}/Projects/${safeName(project.name)}`
: `/Clients/${safeName(company.name)}/Projects/${safeName(project.name)}`;
return <FileBrowser initialPath={fbInitial} rootPath={fbRoot} />;
})()}
{activeTab === 'Folder' && (!company?.name || !project?.name) && (
<div style={{ color: 'var(--text-muted)' }}>No folder available task needs a project and company.</div>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -776,7 +1012,7 @@ export default function TaskDetail() {
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}> <div className="card" style={{ ...CARD, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<div style={LABEL}>Activity</div> <div style={LABEL}>Activity</div>
{activityLog.length === 0 ? ( {activityLog.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No activity yet.</div> <div className="card-empty-center">No activity</div>
) : ( ) : (
<div className="scrollbar-thin-theme" style={{ flex: 1, minHeight: 0, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 10 }}> <div className="scrollbar-thin-theme" style={{ flex: 1, minHeight: 0, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 10 }}>
{activityLog.map(e => ( {activityLog.map(e => (
@@ -797,36 +1033,11 @@ export default function TaskDetail() {
<textarea rows={4} placeholder="Add notes for this submission…" value={reviewNotes} onChange={e => setReviewNotes(e.target.value)} style={{ fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box', resize: 'vertical' }} /> <textarea rows={4} placeholder="Add notes for this submission…" value={reviewNotes} onChange={e => setReviewNotes(e.target.value)} style={{ fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box', resize: 'vertical' }} />
</div> </div>
<div> <div>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 6 }}>Attachments <span style={{ color: 'var(--text-muted)', fontWeight: 400, textTransform: 'none', letterSpacing: 0 }}>(optional)</span></div> <FileAttachment files={reviewFiles} onChange={setReviewFiles} />
<input ref={reviewFileRef} type="file" multiple id="review-file-input" style={{ display: 'none' }} onChange={e => { setReviewFiles(prev => [...prev, ...Array.from(e.target.files)]); e.target.value = ''; }} />
<div
onDragEnter={e => { e.preventDefault(); reviewDragCounter.current++; setReviewDragging(true); }}
onDragLeave={e => { e.preventDefault(); reviewDragCounter.current--; if (reviewDragCounter.current === 0) setReviewDragging(false); }}
onDragOver={e => e.preventDefault()}
onDrop={e => { e.preventDefault(); reviewDragCounter.current = 0; setReviewDragging(false); setReviewFiles(prev => [...prev, ...Array.from(e.dataTransfer.files)]); }}
onClick={() => reviewFileRef.current?.click()}
style={{ border: `2px dashed ${reviewDragging ? 'var(--accent)' : reviewFiles.length > 0 ? 'var(--accent)' : 'var(--border)'}`, borderRadius: 8, padding: '16px', textAlign: 'center', background: reviewDragging ? 'color-mix(in srgb, var(--accent) 8%, var(--bg))' : 'transparent', transition: 'all 160ms', cursor: 'pointer' }}
>
<div style={{ fontSize: 20, marginBottom: 4 }}>{reviewDragging ? '📂' : '📎'}</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>
{reviewDragging ? 'Drop files here' : reviewFiles.length > 0 ? `${reviewFiles.length} file${reviewFiles.length !== 1 ? 's' : ''} attached — click or drag to add more` : 'Click or drag files here'}
</div> </div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 2 }}>Any file type accepted</div> <div className="modal-action-row" style={MODAL_ACTION_ROW}>
</div> <button className="btn btn-outline" style={MODAL_ACTION_BUTTON} disabled={reviewSaving} onClick={handleConfirmReview}>{reviewSaving ? 'Submitting…' : 'Place in Review'}</button>
{reviewFiles.length > 0 && ( <button className="btn btn-outline" style={MODAL_ACTION_BUTTON} disabled={reviewSaving} onClick={() => { setReviewModal(false); setReviewFiles([]); setReviewNotes(''); }}>Cancel</button>
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
{reviewFiles.map((f, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '5px 10px', background: 'var(--card-bg-2)', borderRadius: 6, border: '1px solid var(--border)' }}>
<span style={{ fontSize: 12, color: 'var(--text-primary)' }}>{f.name}</span>
<button type="button" className="btn btn-danger" onClick={() => setReviewFiles(prev => prev.filter((_, j) => j !== i))}>Remove</button>
</div>
))}
</div>
)}
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', paddingTop: 4 }}>
<button className="btn btn-outline" disabled={reviewSaving} onClick={handleConfirmReview}>{reviewSaving ? 'Submitting…' : 'Place in Review'}</button>
<button className="btn btn-outline" disabled={reviewSaving} onClick={() => { setReviewModal(false); setReviewFiles([]); setReviewNotes(''); }}>Cancel</button>
</div> </div>
</div> </div>
</div> </div>
@@ -858,9 +1069,9 @@ export default function TaskDetail() {
<div className="form-group"> <div className="form-group">
<FileAttachment files={revisionFiles} onChange={setRevisionFiles} /> <FileAttachment files={revisionFiles} onChange={setRevisionFiles} />
</div> </div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}> <div className="modal-action-row" style={MODAL_ACTION_ROW}>
<button className="btn btn-outline" disabled={revisionSaving} onClick={() => { setRevisionModal(false); setRevisionForm({ description: '', deadline: '' }); setRevisionFiles([]); }}>Cancel</button> <button className="btn btn-outline" style={MODAL_ACTION_BUTTON} disabled={revisionSaving} onClick={() => { setRevisionModal(false); setRevisionForm({ description: '', deadline: '' }); setRevisionFiles([]); }}>Cancel</button>
<button className="btn btn-outline" style={{ color: 'var(--accent)', borderColor: 'var(--accent)' }} disabled={revisionSaving || !revisionForm.description.trim()} onClick={handleSubmitRevision}> <button className="btn btn-outline" style={{ ...MODAL_ACTION_BUTTON, color: 'var(--accent)', borderColor: 'var(--accent)' }} disabled={revisionSaving || !revisionForm.description.trim()} onClick={handleSubmitRevision}>
{revisionSaving ? 'Submitting…' : 'Submit Revision'} {revisionSaving ? 'Submitting…' : 'Submit Revision'}
</button> </button>
</div> </div>
@@ -868,6 +1079,30 @@ export default function TaskDetail() {
</div> </div>
)} )}
{rejectModal && (
<div style={popupOverlayStyle} onClick={() => { if (!rejectSaving) { setRejectModal(false); setRejectNote(''); } }}>
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', lineHeight: 1.1 }}>Reject Task</div>
<div>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 6 }}>Rejected Notes</div>
<textarea
rows={5}
placeholder="Tell the team what needs to change before approval…"
value={rejectNote}
onChange={e => setRejectNote(e.target.value)}
style={{ width: '100%', fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '8px 10px', resize: 'vertical', fontFamily: 'inherit', boxSizing: 'border-box' }}
/>
</div>
<div className="modal-action-row" style={MODAL_ACTION_ROW}>
<button className="btn btn-outline" style={MODAL_ACTION_BUTTON} disabled={rejectSaving} onClick={() => { setRejectModal(false); setRejectNote(''); }}>Cancel</button>
<button className="btn btn-danger" style={MODAL_ACTION_BUTTON} disabled={rejectSaving || !rejectNote.trim()} onClick={handleSubmitReject}>
{rejectSaving ? 'Saving…' : 'Reject'}
</button>
</div>
</div>
</div>
)}
{amendModal && ( {amendModal && (
<div style={popupOverlayStyle} onClick={() => { setAmendModal(false); setAmendForm({ description: '' }); setAmendFiles([]); }}> <div style={popupOverlayStyle} onClick={() => { setAmendModal(false); setAmendForm({ description: '' }); setAmendFiles([]); }}>
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}> <div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}>
@@ -878,35 +1113,11 @@ export default function TaskDetail() {
</div> </div>
<div> <div>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 6 }}>Attach Files</div> <div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 6 }}>Attach Files</div>
<input ref={amendFileRef} type="file" multiple id="amend-file-input" style={{ display: 'none' }} onChange={e => { setAmendFiles(prev => [...prev, ...Array.from(e.target.files)]); e.target.value = ''; }} /> <FileAttachment files={amendFiles} onChange={setAmendFiles} />
<div
onDragEnter={e => { e.preventDefault(); amendDragCounter.current++; setAmendDragging(true); }}
onDragLeave={e => { e.preventDefault(); amendDragCounter.current--; if (amendDragCounter.current === 0) setAmendDragging(false); }}
onDragOver={e => e.preventDefault()}
onDrop={e => { e.preventDefault(); amendDragCounter.current = 0; setAmendDragging(false); setAmendFiles(prev => [...prev, ...Array.from(e.dataTransfer.files)]); }}
style={{ border: `2px dashed ${amendDragging ? 'var(--accent)' : amendFiles.length > 0 ? 'var(--accent)' : 'var(--border)'}`, borderRadius: 8, padding: '16px', textAlign: 'center', background: amendDragging ? 'color-mix(in srgb, var(--accent) 8%, var(--bg))' : 'transparent', transition: 'all 160ms', cursor: 'pointer' }}
onClick={() => amendFileRef.current?.click()}
>
<div style={{ fontSize: 20, marginBottom: 4 }}>{amendDragging ? '📂' : '📎'}</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>
{amendDragging ? 'Drop files here' : amendFiles.length > 0 ? `${amendFiles.length} file${amendFiles.length !== 1 ? 's' : ''} attached — click or drag to add more` : 'Click or drag files here'}
</div> </div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 2 }}>Any file type accepted</div> <div className="modal-action-row" style={MODAL_ACTION_ROW}>
</div> <button className="btn btn-outline" style={MODAL_ACTION_BUTTON} disabled={amendSaving} onClick={handleSubmitAmendment}>{amendSaving ? 'Saving…' : 'Save'}</button>
{amendFiles.length > 0 && ( <button className="btn btn-outline" style={MODAL_ACTION_BUTTON} disabled={amendSaving} onClick={() => { setAmendModal(false); setAmendForm({ description: '' }); setAmendFiles([]); }}>Cancel</button>
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
{amendFiles.map((f, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '5px 10px', background: 'var(--card-bg-2)', borderRadius: 6, border: '1px solid var(--border)' }}>
<span style={{ fontSize: 12, color: 'var(--text-primary)' }}>{f.name}</span>
<button type="button" className="btn btn-danger" onClick={() => setAmendFiles(prev => prev.filter((_, idx) => idx !== i))}>Remove</button>
</div>
))}
</div>
)}
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', paddingTop: 4 }}>
<button className="btn btn-outline" disabled={amendSaving} onClick={handleSubmitAmendment}>{amendSaving ? 'Saving…' : 'Save'}</button>
<button className="btn btn-outline" disabled={amendSaving} onClick={() => { setAmendModal(false); setAmendForm({ description: '' }); setAmendFiles([]); }}>Cancel</button>
</div> </div>
</div> </div>
</div> </div>
+207 -104
View File
@@ -1,23 +1,36 @@
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import { useState, useEffect, useRef, useMemo } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import Layout from '../components/Layout'; import Layout from '../components/Layout';
import PageLoader from '../components/PageLoader';
import ProfileAvatar from '../components/ProfileAvatar';
import StatusBadge from '../components/StatusBadge'; import StatusBadge from '../components/StatusBadge';
import RequestForm from '../components/RequestForm'; import RequestForm from '../components/RequestForm';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { readPageCache, writePageCache } from '../lib/pageCache'; import { readPageCache, writePageCache } from '../lib/pageCache';
import { withTimeout } from '../lib/withTimeout'; import { withTimeout } from '../lib/withTimeout';
import { getCurrentVersionForTask, getDeadlineSourceSubmission } from '../lib/taskDeadlines'; import { getTaskDerivedState } from '../lib/taskVersions';
import { logActivity } from '../lib/activityLog'; import { logActivity } from '../lib/activityLog';
import { fmtShortDate } from '../lib/dates'; import { fmtShortDate } from '../lib/dates';
import { createInitialSubmissionForRequest, createTaskForRequest, findOrCreateProject } from '../lib/requestSubmission'; import { createInitialSubmissionForRequest, createTaskForRequest, findOrCreateProject } from '../lib/requestSubmission';
import { ensureProjectFolder, ensureRequestFolder, uploadRequestFileToSurvey } from '../lib/folderSync';
import { sendEmail } from '../lib/email'; import { sendEmail } from '../lib/email';
import { uploadFilesToRequestInfo, createTaskFolder } from '../lib/filebrowserFolders';
import SortTh from '../components/SortTh'; import SortTh from '../components/SortTh';
import { useSortable } from '../hooks/useSortable'; import { useSortable } from '../hooks/useSortable';
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles'; import { popupOverlayStyle } from '../lib/popupStyles';
import { useRefetchOnFocus } from '../hooks/useRefetchOnFocus'; import { useLiveRefresh } from '../hooks/useLiveRefresh';
import { useRealtimeSubscription } from '../hooks/useRealtimeSubscription'; import { resolveScopedWorkIds } from '../lib/workScope';
import { mergeSubmissionDisplayNames } from '../lib/submissionDisplay';
import {
TASK_TABLE_TH_STYLE,
TASK_TABLE_TD_BASE,
TASK_MODAL_TITLE_STYLE,
TASK_MODAL_LABEL_STYLE,
TASK_MODAL_INPUT_STYLE,
TASK_MODAL_BUTTON_STYLE,
TASK_MODAL_SHELL_STYLE,
PROJECT_MODAL_SHELL_STYLE,
} from '../lib/taskUi';
const TASK_STAT_ICONS = { const TASK_STAT_ICONS = {
total: '<rect x="3" y="4" width="18" height="16" rx="2"/><line x1="7" y1="9" x2="17" y2="9"/><line x1="7" y1="13" x2="17" y2="13"/>', total: '<rect x="3" y="4" width="18" height="16" rx="2"/><line x1="7" y1="9" x2="17" y2="9"/><line x1="7" y1="13" x2="17" y2="13"/>',
@@ -28,19 +41,11 @@ const TASK_STAT_ICONS = {
done: '<polyline points="4,12 9,17 20,6"/>', done: '<polyline points="4,12 9,17 20,6"/>',
}; };
const TASK_TABLE_TH_STYLE = { fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.6, color: 'var(--text-muted)', textAlign: 'left', padding: '0 0 12px 5px', border: 'none', background: 'transparent', verticalAlign: 'top' };
const TASK_TABLE_TD_BASE = { padding: '5px', border: 'none', background: 'transparent', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
const TASK_MODAL_TITLE_STYLE = { fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' };
const TASK_MODAL_LABEL_STYLE = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 6 };
const TASK_MODAL_INPUT_STYLE = { fontSize: 13, padding: '0 5px', textAlign: 'left', lineHeight: 1 };
const TASK_MODAL_BUTTON_STYLE = { lineHeight: 1, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' };
const TASK_MODAL_SHELL_STYLE = { ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(720px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto' };
const PROJECT_MODAL_SHELL_STYLE = { ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(434px, 100%)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto' };
const TASK_STATUS_SORT_RANK = { not_started: 0, in_progress: 1, on_hold: 2, client_review: 3, client_approved: 4, invoiced: 5, paid: 6 }; const TASK_STATUS_SORT_RANK = { not_started: 0, in_progress: 1, on_hold: 2, client_review: 3, client_approved: 4, invoiced: 5, paid: 6 };
function TaskStatCard({ label, value, iconBg, iconColor, iconPath }) { function TaskStatCard({ label, value, sub, iconBg, iconColor, iconPath }) {
return ( return (
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '14px 21px', minHeight: 86 }}> <div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', minHeight: 120 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div> <div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
<div style={{ width: 27, height: 27, borderRadius: '50%', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}> <div style={{ width: 27, height: 27, borderRadius: '50%', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
@@ -48,6 +53,7 @@ function TaskStatCard({ label, value, iconBg, iconColor, iconPath }) {
</div> </div>
</div> </div>
<div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{value}</div> <div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{value}</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 5 }}>{sub}</div>
</div> </div>
); );
} }
@@ -58,14 +64,17 @@ function TasksStatsRow({ tasks = [] }) {
const onHold = tasks.filter(t => t?.status === 'on_hold').length; const onHold = tasks.filter(t => t?.status === 'on_hold').length;
const inReview = tasks.filter(t => t?.status === 'client_review').length; const inReview = tasks.filter(t => t?.status === 'client_review').length;
const completed = tasks.filter(t => ['client_approved', 'invoiced', 'paid'].includes(t?.status)).length; const completed = tasks.filter(t => ['client_approved', 'invoiced', 'paid'].includes(t?.status)).length;
const total = tasks.length;
const open = Math.max(total - completed, 0);
const pct = (count) => total > 0 ? `${Math.round((count / total) * 100)}% of total` : '0% of total';
return ( return (
<div className="dash-stat-grid" style={{ gridTemplateColumns: '1fr 1fr 1fr 1fr 1fr 1fr' }}> <div className="dash-stat-grid" style={{ gridTemplateColumns: '1fr 1fr 1fr 1fr 1fr 1fr' }}>
<TaskStatCard label="To Do" value={toDo} iconBg="rgba(167,139,250,0.15)" iconColor="#a78bfa" iconPath={TASK_STAT_ICONS.todo} /> <TaskStatCard label="To Do" value={toDo} sub={pct(toDo)} iconBg="rgba(167,139,250,0.15)" iconColor="#a78bfa" iconPath={TASK_STAT_ICONS.todo} />
<TaskStatCard label="In Progress" value={inProgress} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconPath={TASK_STAT_ICONS.progress} /> <TaskStatCard label="In Progress" value={inProgress} sub={pct(inProgress)} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconPath={TASK_STAT_ICONS.progress} />
<TaskStatCard label="On Hold" value={onHold} iconBg="rgba(239,68,68,0.15)" iconColor="#ef4444" iconPath={TASK_STAT_ICONS.hold} /> <TaskStatCard label="On Hold" value={onHold} sub={pct(onHold)} iconBg="rgba(239,68,68,0.15)" iconColor="#ef4444" iconPath={TASK_STAT_ICONS.hold} />
<TaskStatCard label="In Review" value={inReview} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={TASK_STAT_ICONS.review} /> <TaskStatCard label="In Review" value={inReview} sub={pct(inReview)} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={TASK_STAT_ICONS.review} />
<TaskStatCard label="Completed" value={completed} iconBg="rgba(74,222,128,0.15)" iconColor="#4ade80" iconPath={TASK_STAT_ICONS.done} /> <TaskStatCard label="Completed" value={completed} sub={pct(completed)} iconBg="rgba(74,222,128,0.15)" iconColor="#4ade80" iconPath={TASK_STAT_ICONS.done} />
<TaskStatCard label="Total Tasks" value={tasks.length} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={TASK_STAT_ICONS.total} /> <TaskStatCard label="Total Tasks" value={total} sub={`${open} still open`} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={TASK_STAT_ICONS.total} />
</div> </div>
); );
} }
@@ -80,7 +89,7 @@ function TasksPageShell({ statsTasks = [], projects = [], onAddProject = null, c
}; };
const completedStatuses = new Set(['completed', 'cancelled', 'archived', 'done']); const completedStatuses = new Set(['completed', 'cancelled', 'archived', 'done']);
const projectTabs = [ const projectTabs = [
{ id: 'all', label: 'All Projects' }, { id: 'all', label: 'All' },
{ id: 'active', label: 'Active' }, { id: 'active', label: 'Active' },
{ id: 'completed', label: 'Completed' }, { id: 'completed', label: 'Completed' },
]; ];
@@ -89,6 +98,11 @@ function TasksPageShell({ statsTasks = [], projects = [], onAddProject = null, c
const done = completedStatuses.has((p?.status || '').toLowerCase()); const done = completedStatuses.has((p?.status || '').toLowerCase());
return projTab === 'completed' ? done : !done; return projTab === 'completed' ? done : !done;
}); });
const projectTabCounts = {
all: projects.length,
active: projects.filter((p) => !completedStatuses.has((p?.status || '').toLowerCase())).length,
completed: projects.filter((p) => completedStatuses.has((p?.status || '').toLowerCase())).length,
};
const sortedProjects = [...visibleProjects].sort((a, b) => { const sortedProjects = [...visibleProjects].sort((a, b) => {
if (projSortKey === 'status') { if (projSortKey === 'status') {
const ra = completedStatuses.has((a.status || '').toLowerCase()) ? 1 : 0; const ra = completedStatuses.has((a.status || '').toLowerCase()) ? 1 : 0;
@@ -119,7 +133,14 @@ function TasksPageShell({ statsTasks = [], projects = [], onAddProject = null, c
<div style={{ flex: '0 0 calc(30% - 24px)', minWidth: 0, display: 'flex', flexDirection: 'column', minHeight: 0 }}> <div style={{ flex: '0 0 calc(30% - 24px)', minWidth: 0, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10, flexShrink: 0 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10, flexShrink: 0 }}>
{projectTabs.map(t => ( {projectTabs.map(t => (
<button key={t.id} onClick={() => setProjTab(t.id)} className={`section-tab-btn${projTab === t.id ? ' is-active' : ''}`}>{t.label}</button> <button key={t.id} onClick={() => setProjTab(t.id)} className={`section-tab-btn${projTab === t.id ? ' is-active' : ''}`}>
{t.label}
{projectTabCounts[t.id] > 0 && (
<span style={{ marginLeft: 5, fontSize: 12, fontWeight: 600, color: projTab === t.id ? 'var(--accent)' : 'var(--text-muted)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 10, padding: '3px 8px' }}>
{projectTabCounts[t.id]}
</span>
)}
</button>
))} ))}
{onAddProject && ( {onAddProject && (
<div style={{ marginLeft: 'auto' }}> <div style={{ marginLeft: 'auto' }}>
@@ -128,6 +149,9 @@ function TasksPageShell({ statsTasks = [], projects = [], onAddProject = null, c
)} )}
</div> </div>
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', height: '100%', boxSizing: 'border-box', display: 'flex', flexDirection: 'column', minHeight: 0 }}> <div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', height: '100%', boxSizing: 'border-box', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
{sortedProjects.length === 0 ? (
<div className="card-empty-center">No projects</div>
) : (
<div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}> <div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}> <table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup> <colgroup>
@@ -141,9 +165,7 @@ function TasksPageShell({ statsTasks = [], projects = [], onAddProject = null, c
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{sortedProjects.length === 0 ? ( {sortedProjects.map(p => (
<tr><td colSpan={2} style={{ fontSize: 13, color: 'var(--text-muted)', padding: '28px 5px', border: 'none', background: 'transparent', textAlign: 'center' }}>No projects.</td></tr>
) : sortedProjects.map(p => (
<tr key={p.id}> <tr key={p.id}>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)', textAlign: 'left' }}> <td style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)', textAlign: 'left' }}>
<Link to={`/projects/${p.id}`} className="table-link">{p.name}</Link> <Link to={`/projects/${p.id}`} className="table-link">{p.name}</Link>
@@ -161,6 +183,7 @@ function TasksPageShell({ statsTasks = [], projects = [], onAddProject = null, c
</tbody> </tbody>
</table> </table>
</div> </div>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -174,10 +197,7 @@ export default function RequestsPage() {
const isExternal = currentUser?.role === 'external'; const isExternal = currentUser?.role === 'external';
const isClient = currentUser?.role === 'client'; const isClient = currentUser?.role === 'client';
const [refreshKey, setRefreshKey] = useState(0); const { refreshKey } = useLiveRefresh(['tasks', 'projects', 'submissions']);
const refresh = useCallback(() => setRefreshKey(k => k + 1), []);
useRefetchOnFocus(refresh);
useRealtimeSubscription(['tasks', 'projects', 'submissions'], refresh);
const teamCached = isTeam ? readPageCache('team_requests') : null; const teamCached = isTeam ? readPageCache('team_requests') : null;
const extCached = isExternal ? readPageCache(`ext-requests:${currentUser?.id}`, 3 * 60_000) : null; const extCached = isExternal ? readPageCache(`ext-requests:${currentUser?.id}`, 3 * 60_000) : null;
@@ -185,6 +205,7 @@ export default function RequestsPage() {
const [projects, setProjects] = useState(() => teamCached?.projects || extCached?.projects || []); const [projects, setProjects] = useState(() => teamCached?.projects || extCached?.projects || []);
const [tasks, setTasks] = useState(() => teamCached?.tasks || extCached?.tasks || []); const [tasks, setTasks] = useState(() => teamCached?.tasks || extCached?.tasks || []);
const [submissions, setSubmissions] = useState(() => teamCached?.submissions || extCached?.submissions || []); const [submissions, setSubmissions] = useState(() => teamCached?.submissions || extCached?.submissions || []);
const [deliveries, setDeliveries] = useState([]);
const [companies, setCompanies] = useState(() => teamCached?.companies || []); const [companies, setCompanies] = useState(() => teamCached?.companies || []);
const [loading, setLoading] = useState(() => { const [loading, setLoading] = useState(() => {
if (isTeam) return !teamCached; if (isTeam) return !teamCached;
@@ -193,7 +214,7 @@ export default function RequestsPage() {
}); });
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loadError, setLoadError] = useState(false); const [loadError, setLoadError] = useState(false);
const [activeTab, setActiveTab] = useState('not_started'); const [activeTab, setActiveTab] = useState('new_requests');
const [filterCompany, setFilterCompany] = useState(''); const [filterCompany, setFilterCompany] = useState('');
const [filterProject, setFilterProject] = useState(''); const [filterProject, setFilterProject] = useState('');
const { sortKey, sortDir, toggle, sort } = useSortable('status', 'asc'); const { sortKey, sortDir, toggle, sort } = useSortable('status', 'asc');
@@ -238,37 +259,11 @@ export default function RequestsPage() {
try { try {
setError(''); setLoadError(false); setError(''); setLoadError(false);
let scopedProjectIds = null; const { scopedProjectIds, scopedTaskIds } = await withTimeout(
let scopedTaskIds = null; resolveScopedWorkIds(currentUser, { isClient, isExternal }),
10000,
if (isClient) { 'Tasks scope'
const clientCos = currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : []);
const companyIds = clientCos.map(c => c.id).filter(Boolean);
if (companyIds.length > 0) {
const { data: projRows } = await withTimeout(
supabase.from('projects').select('id, tasks(id)').in('company_id', companyIds), 10000, 'Client project scope'
); );
scopedProjectIds = (projRows || []).map(p => p.id);
scopedTaskIds = (projRows || []).flatMap(p => (p.tasks || []).map(t => t.id));
} else {
scopedProjectIds = []; scopedTaskIds = [];
}
}
if (isExternal) {
const { data: memberData } = await withTimeout(
supabase.from('project_members').select('project_id').eq('profile_id', currentUser.id), 10000, 'External project scope'
);
scopedProjectIds = (memberData || []).map(r => r.project_id).filter(Boolean);
if (scopedProjectIds.length > 0) {
const { data: taskRows } = await withTimeout(
supabase.from('tasks').select('id').in('project_id', scopedProjectIds), 10000, 'External task scope'
);
scopedTaskIds = (taskRows || []).map(r => r.id).filter(Boolean);
} else {
scopedTaskIds = [];
}
}
const tasksQ = supabase.from('tasks') const tasksQ = supabase.from('tasks')
.select('id, title, status, current_version, project_id, assigned_name, assigned_to, invoiced, completed_at, submitted_at, assignee:profiles!assigned_to(avatar_url)') .select('id, title, status, current_version, project_id, assigned_name, assigned_to, invoiced, completed_at, submitted_at, assignee:profiles!assigned_to(avatar_url)')
@@ -298,12 +293,38 @@ export default function RequestsPage() {
); );
if (cancelled) return; if (cancelled) return;
setProjects(p || []); setTasks(t || []); setSubmissions(subs || []); setCompanies(co || []); const allSubs = subs || [];
const submitterIds = [...new Set(allSubs.map((submission) => submission.submitted_by).filter(Boolean))];
let submitterProfiles = [];
if (submitterIds.length > 0) {
const { data: profileRows } = await withTimeout(
supabase.from('profiles').select('id, name').in('id', submitterIds),
10000,
'Tasks submitter profiles load'
);
submitterProfiles = profileRows || [];
}
const hydratedSubs = mergeSubmissionDisplayNames(allSubs, submitterProfiles);
let deliveryRows = [];
if (hydratedSubs.length > 0) {
const { data: delRows } = await withTimeout(
supabase.from('deliveries').select('id, submission_id, version_number, sent_at').in('submission_id', hydratedSubs.map(sub => sub.id)),
10000,
'Tasks deliveries load'
);
deliveryRows = delRows || [];
}
setProjects(p || []);
setTasks(t || []);
setSubmissions(hydratedSubs);
setDeliveries(deliveryRows);
setCompanies(co || []);
if (isTeam) { if (isTeam) {
writePageCache('team_requests', { submissions: subs || [], tasks: t || [], projects: p || [], companies: co || [] }); writePageCache('team_requests', { submissions: hydratedSubs, tasks: t || [], projects: p || [], companies: co || [] });
} else if (isExternal) { } else if (isExternal) {
writePageCache(`ext-requests:${currentUser.id}`, { projects: p || [], tasks: t || [], submissions: subs || [] }); writePageCache(`ext-requests:${currentUser.id}`, { projects: p || [], tasks: t || [], submissions: hydratedSubs });
} }
} catch (err) { } catch (err) {
console.error('Tasks page load failed:', err); console.error('Tasks page load failed:', err);
@@ -317,7 +338,7 @@ export default function RequestsPage() {
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [isTeam, isExternal, isClient, currentUser?.id, refreshKey]); // eslint-disable-line react-hooks/exhaustive-deps }, [isTeam, isExternal, isClient, currentUser?.id, refreshKey]); // eslint-disable-line react-hooks/exhaustive-deps
const handleAddRequest = async (formData, _files, existingProjects) => { const handleAddRequest = async (formData, files, existingProjects) => {
if (addSaving) return; if (addSaving) return;
setAddSaving(true); setAddError(''); setAddSaving(true); setAddError('');
try { try {
@@ -326,7 +347,6 @@ export default function RequestsPage() {
const { task } = await createTaskForRequest({ projectId: resolvedProject.id, title: formData.title.trim(), requestKey: addRequestKey }); const { task } = await createTaskForRequest({ projectId: resolvedProject.id, title: formData.title.trim(), requestKey: addRequestKey });
if (!task) throw new Error('Failed to create task.'); if (!task) throw new Error('Failed to create task.');
const taskCompany = companies?.find(c => c.id === formData.companyId); const taskCompany = companies?.find(c => c.id === formData.companyId);
createTaskFolder(taskCompany?.name, resolvedProject.name, formData.title.trim()).catch(() => {});
const { submission: sub } = await createInitialSubmissionForRequest({ const { submission: sub } = await createInitialSubmissionForRequest({
taskId: task.id, requestKey: addRequestKey, isHot: formData.isHot, taskId: task.id, requestKey: addRequestKey, isHot: formData.isHot,
serviceType: formData.serviceType, signFamily: formData.signFamily, serviceType: formData.serviceType, signFamily: formData.signFamily,
@@ -335,12 +355,67 @@ export default function RequestsPage() {
description: formData.description, submittedBy: formData.requestedBy, submittedByName: formData.requestedByName, description: formData.description, submittedBy: formData.requestedBy, submittedByName: formData.requestedByName,
}); });
if (!sub) throw new Error('Failed to create submission.'); if (!sub) throw new Error('Failed to create submission.');
try {
await ensureRequestFolder({ projectId: resolvedProject.id, taskTitle: formData.title.trim() });
} catch (folderError) {
console.error('Request folder sync failed:', folderError);
}
if (files.length > 0) {
const taskCompany = companies?.find(c => c.id === formData.companyId);
setShowAddForm(false);
setUploadProgress({ active: true, label: 'Uploading files…', percent: 0 });
for (let fi = 0; fi < files.length; fi++) {
const file = files[fi];
setUploadProgress({ active: true, label: file.name, percent: Math.round((fi / files.length) * 100) });
const path = `${task.id}/Survey/${Date.now()}_${file.name}`;
const { data: uploaded, error: uploadError } = await supabase.storage.from('submissions').upload(path, file);
if (uploadError) {
await supabase.from('tasks').delete().eq('id', task.id);
setUploadProgress({ active: false, label: '', percent: 0 });
throw new Error(`Upload failed: ${uploadError.message}`);
}
if (uploaded) {
const { error: fileErr } = await supabase.from('submission_files').insert({ submission_id: sub.id, name: file.name, storage_path: path, size: file.size });
if (fileErr) {
await supabase.from('tasks').delete().eq('id', task.id);
setUploadProgress({ active: false, label: '', percent: 0 });
throw new Error(`File record failed: ${fileErr.message}`);
}
try {
await uploadRequestFileToSurvey({
projectId: resolvedProject.id,
taskTitle: formData.title.trim(),
storagePath: path,
fileName: file.name,
});
} catch (mirrorError) {
console.error('Survey folder mirror failed:', mirrorError);
}
}
}
setUploadProgress({ active: false, label: '', percent: 0 });
}
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'request_submitted', taskId: task.id, taskTitle: task.title, projectId: resolvedProject.id, projectName: resolvedProject.name }).catch(() => {}); logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'request_submitted', taskId: task.id, taskTitle: task.title, projectId: resolvedProject.id, projectName: resolvedProject.name }).catch(() => {});
sendEmail('new_request', 'hello@fourgebranding.com', {
clientName: formData.requestedByName || currentUser.name,
clientEmail: currentUser.email || 'hello@fourgebranding.com',
company: taskCompany?.name || '',
serviceType: formData.serviceType,
projectName: resolvedProject.name,
projectId: resolvedProject.id,
deadline: formData.deadline,
description: formData.description,
taskId: task.id,
}).catch(() => {});
const [{ data: newSubs }, { data: newTasks }] = await Promise.all([ const [{ data: newSubs }, { data: newTasks }] = await Promise.all([
supabase.from('submissions').select('id, task_id, submitted_at, submitted_by, submitted_by_name, is_hot, service_type, deadline, version_number, type').order('submitted_at', { ascending: false }), supabase.from('submissions').select('id, task_id, submitted_at, submitted_by, submitted_by_name, is_hot, service_type, deadline, version_number, type').order('submitted_at', { ascending: false }),
supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_name, assigned_to, invoiced, completed_at, submitted_at, assignee:profiles!assigned_to(avatar_url)').order('submitted_at', { ascending: false }), supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_name, assigned_to, invoiced, completed_at, submitted_at, assignee:profiles!assigned_to(avatar_url)').order('submitted_at', { ascending: false }),
]); ]);
setSubmissions(newSubs || []); setTasks(newTasks || []); const submitterIds = [...new Set((newSubs || []).map((submission) => submission.submitted_by).filter(Boolean))];
const { data: submitterProfiles } = submitterIds.length > 0
? await supabase.from('profiles').select('id, name').in('id', submitterIds)
: { data: [] };
setSubmissions(mergeSubmissionDisplayNames(newSubs || [], submitterProfiles || [])); setTasks(newTasks || []);
setShowAddForm(false); setAddFormKey(k => k + 1); setAddRequestKey(crypto.randomUUID()); setShowAddForm(false); setAddFormKey(k => k + 1); setAddRequestKey(crypto.randomUUID());
} catch (err) { setAddError(err.message); } } catch (err) { setAddError(err.message); }
finally { setAddSaving(false); } finally { setAddSaving(false); }
@@ -355,7 +430,6 @@ export default function RequestsPage() {
const resolvedProject = await findOrCreateProject(formData.companyId, formData.project.trim(), existingProjects); const resolvedProject = await findOrCreateProject(formData.companyId, formData.project.trim(), existingProjects);
const { task } = await createTaskForRequest({ projectId: resolvedProject.id, title: formData.title.trim(), requestKey: addRequestKey }); const { task } = await createTaskForRequest({ projectId: resolvedProject.id, title: formData.title.trim(), requestKey: addRequestKey });
if (!task) throw new Error('Failed to create task.'); if (!task) throw new Error('Failed to create task.');
createTaskFolder(selectedCompany?.name, resolvedProject.name, formData.title.trim()).catch(() => {});
const { submission } = await createInitialSubmissionForRequest({ const { submission } = await createInitialSubmissionForRequest({
taskId: task.id, requestKey: addRequestKey, isHot: formData.isHot, taskId: task.id, requestKey: addRequestKey, isHot: formData.isHot,
serviceType: formData.serviceType, signFamily: formData.signFamily, serviceType: formData.serviceType, signFamily: formData.signFamily,
@@ -363,28 +437,42 @@ export default function RequestsPage() {
deadline: formData.deadline, deadline: formData.deadline,
description: formData.description, submittedBy: currentUser.id, submittedByName: currentUser.name, description: formData.description, submittedBy: currentUser.id, submittedByName: currentUser.name,
}); });
try {
await ensureRequestFolder({ projectId: resolvedProject.id, taskTitle: formData.title.trim() });
} catch (folderError) {
console.error('Request folder sync failed:', folderError);
}
if (submission && files.length > 0) { if (submission && files.length > 0) {
setShowAddForm(false); setShowAddForm(false);
setUploadProgress({ active: true, label: 'Uploading files…', percent: 0 }); setUploadProgress({ active: true, label: 'Uploading files…', percent: 0 });
for (let fi = 0; fi < files.length; fi++) { for (let fi = 0; fi < files.length; fi++) {
const file = files[fi]; const file = files[fi];
setUploadProgress({ active: true, label: file.name, percent: Math.round((fi / files.length) * 100) }); setUploadProgress({ active: true, label: file.name, percent: Math.round((fi / files.length) * 100) });
const path = `${task.id}/${Date.now()}_${file.name}`; const path = `${task.id}/Survey/${Date.now()}_${file.name}`;
const { data: uploaded, error: uploadError } = await supabase.storage.from('submissions').upload(path, file); const { data: uploaded, error: uploadError } = await supabase.storage.from('submissions').upload(path, file);
if (uploadError) { await supabase.from('tasks').delete().eq('id', task.id); setUploadProgress({ active: false, label: '', percent: 0 }); throw new Error(`Upload failed: ${uploadError.message}`); } if (uploadError) { await supabase.from('tasks').delete().eq('id', task.id); setUploadProgress({ active: false, label: '', percent: 0 }); throw new Error(`Upload failed: ${uploadError.message}`); }
if (uploaded) { if (uploaded) {
const { error: fileErr } = await supabase.from('submission_files').insert({ submission_id: submission.id, name: file.name, storage_path: path, size: file.size }); const { error: fileErr } = await supabase.from('submission_files').insert({ submission_id: submission.id, name: file.name, storage_path: path, size: file.size });
if (fileErr) { await supabase.from('tasks').delete().eq('id', task.id); setUploadProgress({ active: false, label: '', percent: 0 }); throw new Error(`File record failed: ${fileErr.message}`); } if (fileErr) { await supabase.from('tasks').delete().eq('id', task.id); setUploadProgress({ active: false, label: '', percent: 0 }); throw new Error(`File record failed: ${fileErr.message}`); }
try {
await uploadRequestFileToSurvey({
projectId: resolvedProject.id,
taskTitle: formData.title.trim(),
storagePath: path,
fileName: file.name,
});
} catch (mirrorError) {
console.error('Survey folder mirror failed:', mirrorError);
}
} }
} }
uploadFilesToRequestInfo(files, selectedCompany?.name, resolvedProject.name, formData.title.trim()).catch(() => {});
setUploadProgress({ active: false, label: '', percent: 0 }); setUploadProgress({ active: false, label: '', percent: 0 });
} }
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'request_submitted', taskId: task.id, taskTitle: task.title, projectId: resolvedProject.id, projectName: resolvedProject.name }).catch(() => {}); logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'request_submitted', taskId: task.id, taskTitle: task.title, projectId: resolvedProject.id, projectName: resolvedProject.name }).catch(() => {});
sendEmail('new_request', 'hello@fourgebranding.com', { sendEmail('new_request', 'hello@fourgebranding.com', {
clientName: currentUser.name, clientEmail: currentUser.email, clientName: currentUser.name, clientEmail: currentUser.email,
company: selectedCompany?.name || '', serviceType: formData.serviceType, company: selectedCompany?.name || '', serviceType: formData.serviceType,
projectName: formData.project, deadline: formData.deadline, projectName: formData.project, projectId: resolvedProject.id, deadline: formData.deadline,
description: formData.description, taskId: task.id, description: formData.description, taskId: task.id,
}).catch(() => {}); }).catch(() => {});
const refreshCompanyIds = clientCos.map(c => c.id).filter(Boolean); const refreshCompanyIds = clientCos.map(c => c.id).filter(Boolean);
@@ -399,7 +487,11 @@ export default function RequestsPage() {
.select('id, task_id, submitted_at, submitted_by, submitted_by_name, is_hot, service_type, deadline, version_number, type') .select('id, task_id, submitted_at, submitted_by, submitted_by_name, is_hot, service_type, deadline, version_number, type')
.in('task_id', refreshTaskIds.length > 0 ? refreshTaskIds : ['__none__']) .in('task_id', refreshTaskIds.length > 0 ? refreshTaskIds : ['__none__'])
.order('submitted_at', { ascending: false }); .order('submitted_at', { ascending: false });
setProjects(refreshProjects || []); setTasks(newTasks || []); setSubmissions(newSubs || []); const submitterIds = [...new Set((newSubs || []).map((submission) => submission.submitted_by).filter(Boolean))];
const { data: submitterProfiles } = submitterIds.length > 0
? await supabase.from('profiles').select('id, name').in('id', submitterIds)
: { data: [] };
setProjects(refreshProjects || []); setTasks(newTasks || []); setSubmissions(mergeSubmissionDisplayNames(newSubs || [], submitterProfiles || []));
setShowAddForm(false); setAddFormKey(k => k + 1); setAddRequestKey(crypto.randomUUID()); setShowAddForm(false); setAddFormKey(k => k + 1); setAddRequestKey(crypto.randomUUID());
} catch (err) { setAddError(err.message); } } catch (err) { setAddError(err.message); }
finally { setAddSaving(false); } finally { setAddSaving(false); }
@@ -417,6 +509,11 @@ export default function RequestsPage() {
.from('projects').insert({ name, company_id: addProjectForm.companyId, status: 'active' }) .from('projects').insert({ name, company_id: addProjectForm.companyId, status: 'active' })
.select('id, name, status, company_id, company:companies(id, name)').single(); .select('id, name, status, company_id, company:companies(id, name)').single();
if (insertError) throw insertError; if (insertError) throw insertError;
try {
await ensureProjectFolder({ projectId: newProject.id });
} catch (folderError) {
console.error('Project folder sync failed:', folderError);
}
setProjects(prev => [...prev, newProject]); setProjects(prev => [...prev, newProject]);
setShowAddProjectForm(false); setAddProjectForm({ name: '', companyId: '' }); setShowAddProjectForm(false); setAddProjectForm({ name: '', companyId: '' });
} catch (err) { setAddProjectError(err.message || 'Failed to create project.'); } } catch (err) { setAddProjectError(err.message || 'Failed to create project.'); }
@@ -436,37 +533,29 @@ export default function RequestsPage() {
const allRows = useMemo(() => { const allRows = useMemo(() => {
if (isClient) { if (isClient) {
return tasks.map(task => { return tasks.map(task => {
const sub = submissions.find(s => s.task_id === task.id && s.type === 'initial'); const derived = getTaskDerivedState(task, submissions, deliveries);
return { return {
id: task.id, title: task.title, status: task.status, projectId: task.project_id, id: task.id, title: task.title, status: task.status, projectId: task.project_id,
serviceType: sub?.service_type || '—', deadline: sub?.deadline || null, serviceType: derived.serviceType, deadline: derived.initialSubmission?.deadline || derived.deadline || null,
version: task.current_version || 0, isHot: false, version: derived.currentVersion, isHot: false,
assignedName: task.assigned_name || null, assigneeAvatar: task.assignee?.avatar_url || null, assignedTo: task.assigned_to || null, assignedName: task.assigned_name || null, assigneeAvatar: task.assignee?.avatar_url || null, assignedTo: task.assigned_to || null,
submittedAt: task.submitted_at ? new Date(task.submitted_at).getTime() : 0, submittedAt: derived.latestActivityAt,
}; };
}); });
} }
return tasks.map(task => { return tasks.map(task => {
const taskSubs = submissions.filter(s => s.task_id === task.id); const derived = getTaskDerivedState(task, submissions, deliveries);
const deadlineSource = getDeadlineSourceSubmission(task, taskSubs); if (derived.visibleTaskSubs.length === 0 || !derived.deadlineSource) return null;
if (!deadlineSource) return null;
const currentVersion = getCurrentVersionForTask(task, taskSubs);
const latestGroup = taskSubs.filter(s => s.version_number === currentVersion);
const serviceType = submissions.find(s => s.task_id === task.id && s.type === 'initial')?.service_type
|| submissions.find(s => s.task_id === task.id && s.service_type)?.service_type
|| deadlineSource.service_type || '—';
return { return {
id: task.id, title: task.title, status: task.status, projectId: task.project_id, id: task.id, title: task.title, status: task.status, projectId: task.project_id,
serviceType, deadline: deadlineSource.deadline, serviceType: derived.serviceType, deadline: derived.deadline,
version: deadlineSource.version_number ?? 0, version: derived.currentVersion,
isHot: deadlineSource.is_hot || false, isHot: derived.isHot,
assignedName: task.assigned_name || null, assigneeAvatar: task.assignee?.avatar_url || null, assignedTo: task.assigned_to || null, assignedName: task.assigned_name || null, assigneeAvatar: task.assignee?.avatar_url || null, assignedTo: task.assigned_to || null,
submittedAt: latestGroup.length > 0 submittedAt: derived.latestActivityAt,
? Math.max(...latestGroup.map(s => new Date(s.submitted_at).getTime()))
: (task.submitted_at ? new Date(task.submitted_at).getTime() : 0),
}; };
}).filter(Boolean); }).filter(Boolean);
}, [tasks, submissions, isClient]); // eslint-disable-line react-hooks/exhaustive-deps }, [tasks, submissions, deliveries, isClient]); // eslint-disable-line react-hooks/exhaustive-deps
const filteredRows = useMemo(() => { const filteredRows = useMemo(() => {
return allRows return allRows
@@ -482,16 +571,29 @@ export default function RequestsPage() {
const doneStatuses = new Set(['client_approved', 'invoiced', 'paid']); const doneStatuses = new Set(['client_approved', 'invoiced', 'paid']);
const tabs = [ const tabs = [
{ id: 'all', label: 'All Tasks' }, { id: 'all', label: 'All' },
{ id: 'not_started', label: 'To Do' }, { id: 'new_requests', label: 'New Requests' },
{ id: 'revisions', label: 'Revisions' },
{ id: 'in_progress', label: 'In Progress' }, { id: 'in_progress', label: 'In Progress' },
{ id: 'on_hold', label: 'On Hold' }, { id: 'on_hold', label: 'On Hold' },
{ id: 'client_review', label: 'In Review' }, { id: 'client_review', label: 'In Review' },
{ id: 'completed', label: 'Completed' }, { id: 'completed', label: 'Completed' },
]; ];
const tabCounts = {
all: filteredRows.length,
new_requests: filteredRows.filter(r => r.status === 'not_started' && Number(r.version || 0) === 0).length,
revisions: filteredRows.filter(r => r.status === 'not_started' && Number(r.version || 0) >= 1).length,
in_progress: filteredRows.filter(r => r.status === 'in_progress').length,
on_hold: filteredRows.filter(r => r.status === 'on_hold').length,
client_review: filteredRows.filter(r => r.status === 'client_review').length,
completed: filteredRows.filter(r => doneStatuses.has(r.status)).length,
};
const tabRows = activeTab === 'all' ? filteredRows const tabRows = activeTab === 'all' ? filteredRows
: activeTab === 'completed' ? filteredRows.filter(r => doneStatuses.has(r.status)) : activeTab === 'completed' ? filteredRows.filter(r => doneStatuses.has(r.status))
: activeTab === 'new_requests' ? filteredRows.filter(r => r.status === 'not_started' && Number(r.version || 0) === 0)
: activeTab === 'revisions' ? filteredRows.filter(r => r.status === 'not_started' && Number(r.version || 0) >= 1)
: filteredRows.filter(r => r.status === activeTab); : filteredRows.filter(r => r.status === activeTab);
const sortedRows = sort(tabRows, (row, key) => { const sortedRows = sort(tabRows, (row, key) => {
@@ -517,7 +619,6 @@ export default function RequestsPage() {
const renderRow = (row) => { const renderRow = (row) => {
const project = projects.find(p => p.id === row.projectId); const project = projects.find(p => p.id === row.projectId);
const initials = row.assignedName ? row.assignedName.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase() : null;
return ( return (
<tr key={row.id}> <tr key={row.id}>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}> <td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>
@@ -531,14 +632,11 @@ export default function RequestsPage() {
</td> </td>
<td style={{ ...TASK_TABLE_TD_BASE, textAlign: 'center' }}> <td style={{ ...TASK_TABLE_TD_BASE, textAlign: 'center' }}>
{(() => { {(() => {
const avatarStyle = { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, borderRadius: '50%', overflow: 'hidden', verticalAlign: 'middle', flexShrink: 0 };
if (row.assignedName && row.assignedTo) { if (row.assignedName && row.assignedTo) {
const portrait = row.assigneeAvatar const portrait = <ProfileAvatar name={row.assignedName} avatarUrl={row.assigneeAvatar} size={26} fontSize={10} />;
? <div title={row.assignedName} style={avatarStyle}><img src={row.assigneeAvatar} alt={row.assignedName} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /></div>
: <div title={row.assignedName} style={{ ...avatarStyle, background: 'var(--accent)' }}><span style={{ fontSize: 10, fontWeight: 600, color: '#000', lineHeight: 1 }}>{initials}</span></div>;
return <Link to={`/profile/${row.assignedTo}`} style={{ display: 'inline-flex' }}>{portrait}</Link>; return <Link to={`/profile/${row.assignedTo}`} style={{ display: 'inline-flex' }}>{portrait}</Link>;
} }
return <div title="Unassigned" style={{ ...avatarStyle, background: 'rgba(255,255,255,0.08)' }}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.3)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg></div>; return <div title="Unassigned" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, borderRadius: '50%', overflow: 'hidden', verticalAlign: 'middle', flexShrink: 0, background: 'rgba(255,255,255,0.08)' }}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.3)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg></div>;
})()} })()}
</td> </td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 11, textAlign: 'center', color: row.isHot ? '#ef4444' : 'var(--text-primary)', textTransform: 'uppercase', letterSpacing: 0.5 }}> <td style={{ ...TASK_TABLE_TD_BASE, fontSize: 11, textAlign: 'center', color: row.isHot ? '#ef4444' : 'var(--text-primary)', textTransform: 'uppercase', letterSpacing: 0.5 }}>
@@ -554,7 +652,7 @@ export default function RequestsPage() {
); );
}; };
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>; if (loading) return <Layout><PageLoader /></Layout>;
if (loadError) return <Layout><div style={{ padding: 24 }}><p style={{ color: 'var(--text-muted)', marginBottom: 12 }}>Failed to load. Check your connection and try again.</p><button className="btn btn-outline" onClick={() => window.location.reload()}>Retry</button></div></Layout>; if (loadError) return <Layout><div style={{ padding: 24 }}><p style={{ color: 'var(--text-muted)', marginBottom: 12 }}>Failed to load. Check your connection and try again.</p><button className="btn btn-outline" onClick={() => window.location.reload()}>Retry</button></div></Layout>;
const filteredStatTasks = filteredRows.map(r => tasks.find(t => t.id === r.id)).filter(Boolean); const filteredStatTasks = filteredRows.map(r => tasks.find(t => t.id === r.id)).filter(Boolean);
@@ -590,7 +688,7 @@ export default function RequestsPage() {
<input type="text" placeholder="e.g. Brand Identity 2026" value={addProjectForm.name} onChange={e => setAddProjectForm(f => ({ ...f, name: e.target.value }))} required autoFocus style={TASK_MODAL_INPUT_STYLE} /> <input type="text" placeholder="e.g. Brand Identity 2026" value={addProjectForm.name} onChange={e => setAddProjectForm(f => ({ ...f, name: e.target.value }))} required autoFocus style={TASK_MODAL_INPUT_STYLE} />
</div> </div>
{addProjectError && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>{addProjectError}</div>} {addProjectError && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>{addProjectError}</div>}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 20 }}> <div className="modal-action-row" style={{ marginTop: 20 }}>
<button type="submit" className="btn btn-outline" style={TASK_MODAL_BUTTON_STYLE} disabled={addProjectSaving}>{addProjectSaving ? 'Saving...' : 'Save'}</button> <button type="submit" className="btn btn-outline" style={TASK_MODAL_BUTTON_STYLE} disabled={addProjectSaving}>{addProjectSaving ? 'Saving...' : 'Save'}</button>
<button type="button" className="btn btn-outline" style={TASK_MODAL_BUTTON_STYLE} onClick={() => { setShowAddProjectForm(false); setAddProjectForm({ name: '', companyId: '' }); setAddProjectError(''); }}>Cancel</button> <button type="button" className="btn btn-outline" style={TASK_MODAL_BUTTON_STYLE} onClick={() => { setShowAddProjectForm(false); setAddProjectForm({ name: '', companyId: '' }); setAddProjectError(''); }}>Cancel</button>
</div> </div>
@@ -627,6 +725,11 @@ export default function RequestsPage() {
{tabs.map(t => ( {tabs.map(t => (
<button key={t.id} onClick={() => setActiveTab(t.id)} className={`section-tab-btn${activeTab === t.id ? ' is-active' : ''}`}> <button key={t.id} onClick={() => setActiveTab(t.id)} className={`section-tab-btn${activeTab === t.id ? ' is-active' : ''}`}>
{t.label} {t.label}
{tabCounts[t.id] > 0 && (
<span style={{ marginLeft: 5, fontSize: 12, fontWeight: 600, color: activeTab === t.id ? 'var(--accent)' : 'var(--text-muted)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 10, padding: '3px 8px' }}>
{tabCounts[t.id]}
</span>
)}
</button> </button>
))} ))}
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }} ref={companyFilterMenuRef}> <div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }} ref={companyFilterMenuRef}>
@@ -692,9 +795,9 @@ export default function RequestsPage() {
{/* Task table */} {/* Task table */}
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', background: 'var(--card-bg)', borderRadius: 8, border: '1px solid var(--border)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', padding: '18px 21px' }}> <div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', background: 'var(--card-bg)', borderRadius: 8, border: '1px solid var(--border)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', padding: '18px 21px' }}>
{allRows.length === 0 ? ( {allRows.length === 0 ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No tasks yet.</div> <div className="card-empty-center">No tasks</div>
) : sortedRows.length === 0 ? ( ) : sortedRows.length === 0 ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No tasks.</div> <div className="card-empty-center">No tasks</div>
) : ( ) : (
<div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}> <div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table className="table-sticky-head table-no-row-hover" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}> <table className="table-sticky-head table-no-row-hover" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
+326 -94
View File
@@ -1,169 +1,401 @@
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo, useRef } from 'react';
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import { Link } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout'; import Layout from '../../components/Layout';
import LoadingButton from '../../components/LoadingButton'; import LoadingButton from '../../components/LoadingButton';
import SortTh from '../../components/SortTh'; import SortTh from '../../components/SortTh';
import StatusBadge from '../../components/StatusBadge'; import StatusBadge from '../../components/StatusBadge';
import { supabase } from '../../lib/supabase'; import { supabase } from '../../lib/supabase';
import { generateInvoicePDF } from '../../lib/invoice'; import { generateInvoicePDF, generateReceiptPDF } from '../../lib/invoice';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/AuthContext';
import { useSortable } from '../../hooks/useSortable'; import { useSortable } from '../../hooks/useSortable';
import { popupOverlayStyle, popupSurfaceStyle } from '../../lib/popupStyles';
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' }; const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
const invoiceStatusLabel = (status) => {
if (status === 'sent') return 'Invoiced';
return status ? status.charAt(0).toUpperCase() + status.slice(1) : '—';
};
function ClientFinanceStatCard({ label, value, sub, iconBg, iconColor, iconPath }) {
return (
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', minHeight: 120 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
<div style={{ width: 27, height: 27, borderRadius: '50%', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={iconColor} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" dangerouslySetInnerHTML={{ __html: iconPath }} />
</div>
</div>
<div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{value}</div>
{sub ? <div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 5 }}>{sub}</div> : null}
</div>
);
}
function ClientFinanceTooltip({ active, payload, label, year }) {
if (!active || !payload?.length) return null;
return (
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '10px 14px', fontSize: 12 }}>
<div style={{ fontWeight: 600, marginBottom: 6, color: 'var(--text-primary)' }}>{label} {year}</div>
{payload.map(p => (
<div key={p.dataKey} style={{ color: p.color, marginBottom: 2 }}>{p.name}: <strong>${Number(p.value).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</strong></div>
))}
</div>
);
}
function ClientInvoiceModal({ invoice, onClose }) {
const navigate = useNavigate();
const [downloading, setDownloading] = useState('');
if (!invoice) return null;
const items = invoice.items || [];
const company = invoice.company || null;
const isOverdue = invoice.status !== 'paid' && invoice.due_date && new Date(invoice.due_date) < new Date();
const handleDownloadInvoice = async () => {
if (downloading) return;
setDownloading('invoice');
try {
await generateInvoicePDF(invoice, company, items);
} finally {
setDownloading('');
}
};
const handleDownloadReceipt = async () => {
if (downloading) return;
setDownloading('receipt');
try {
await generateReceiptPDF(invoice, company, items);
} finally {
setDownloading('');
}
};
const openPay = () => {
onClose?.();
navigate(`/pay/${encodeURIComponent(invoice.invoice_number)}`);
};
const sortedItems = [...items].sort((a, b) => {
const aOrder = Number(a.sort_order ?? 0);
const bOrder = Number(b.sort_order ?? 0);
return aOrder - bOrder;
});
return (
<div style={popupOverlayStyle} onClick={onClose}>
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', maxWidth: 1180, maxHeight: '86vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16, paddingBottom: 18, marginBottom: 18, borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
<div>
<div style={{ fontSize: 28, fontWeight: 500, lineHeight: 1.2 }}>{invoice.invoice_number}</div>
<div style={{ fontSize: 13, color: 'var(--text-secondary)', marginTop: 4 }}>
{company?.id ? (
<Link to={`/company/${company.id}`} className="dashboard-inline-link" onClick={onClose}>
{company.name}
</Link>
) : (
company?.name || invoice.bill_to || 'Invoice'
)}
</div>
</div>
<div className="modal-action-row" style={{ alignItems: 'center' }}>
<StatusBadge
status={statusColor[invoice.status] || 'not_started'}
label={`${invoiceStatusLabel(invoice.status)}${isOverdue ? ' · Overdue' : ''}`}
/>
{invoice.status === 'sent' && (
<button className="btn btn-outline" onClick={openPay}>Pay Invoice</button>
)}
<LoadingButton className="btn btn-outline" loading={downloading === 'invoice'} disabled={Boolean(downloading)} loadingText="Generating…" onClick={handleDownloadInvoice}>
Download Invoice
</LoadingButton>
{invoice.status === 'paid' && (
<LoadingButton className="btn btn-outline" loading={downloading === 'receipt'} disabled={Boolean(downloading)} loadingText="Generating…" onClick={handleDownloadReceipt}>
Download Receipt
</LoadingButton>
)}
<button className="btn btn-outline" onClick={onClose}>Close</button>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)', gap: 24, marginBottom: 24, flexShrink: 0 }}>
<div className="card" style={{ margin: 0 }}>
<div className="card-title">Bill To</div>
<div style={{ fontSize: 15, fontWeight: 400 }}>{invoice.bill_to || company?.name || '—'}</div>
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 4 }}>
{company?.id ? (
<Link to={`/company/${company.id}`} className="dashboard-inline-link" onClick={onClose}>
{company.name}
</Link>
) : (
company?.name || 'Fourge Branding Client Invoice'
)}
</div>
</div>
<div className="card" style={{ margin: 0 }}>
<div className="card-title">Invoice Details</div>
<div className="detail-grid" style={{ marginBottom: 0 }}>
<div className="detail-item"><label>Invoice Date</label><p>{invoice.invoice_date ? new Date(invoice.invoice_date).toLocaleDateString() : '—'}</p></div>
<div className="detail-item"><label>Due Date</label><p style={{ color: isOverdue ? 'var(--danger)' : 'inherit' }}>{invoice.due_date ? new Date(invoice.due_date).toLocaleDateString() : '—'}</p></div>
<div className="detail-item"><label>Terms</label><p>Net 30</p></div>
<div className="detail-item"><label>Status</label><p><StatusBadge status={statusColor[invoice.status] || 'not_started'} label={invoiceStatusLabel(invoice.status)} /></p></div>
<div className="detail-item"><label>Total</label><p style={{ fontSize: 18, fontWeight: 400, color: 'var(--accent)' }}>${Number(invoice.total || 0).toFixed(2)}</p></div>
{invoice.paid_at && <div className="detail-item"><label>Paid On</label><p style={{ color: 'var(--success, #16a34a)', fontWeight: 400 }}>{new Date(invoice.paid_at).toLocaleDateString()}</p></div>}
</div>
</div>
</div>
<div className="card" style={{ margin: 0, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div className="card-title">Line Items</div>
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '12%' }} />
<col style={{ width: '46%' }} />
<col style={{ width: '14%' }} />
<col style={{ width: '14%' }} />
<col style={{ width: '14%' }} />
</colgroup>
<thead>
<tr>
<th>Type</th>
<th>Description</th>
<th style={{ textAlign: 'center' }}>Qty</th>
<th style={{ textAlign: 'right' }}>Unit Price</th>
<th style={{ textAlign: 'right' }}>Total</th>
</tr>
</thead>
<tbody>
{sortedItems.map(item => (
<tr key={item.id}>
<td style={{ padding: '5px 0' }}>
<span className={`badge ${item.submission_id ? 'badge-client_revision' : 'badge-initial'}`}>
{item.submission_id ? 'Revision' : 'New'}
</span>
</td>
<td style={{ padding: '5px 0', fontSize: 13 }}>{item.description}</td>
<td style={{ padding: '5px 0', fontSize: 13, textAlign: 'center' }}>{item.quantity}</td>
<td style={{ padding: '5px 0', fontSize: 13, textAlign: 'right' }}>${Number(item.unit_price || 0).toFixed(2)}</td>
<td style={{ padding: '5px 0', fontSize: 13, textAlign: 'right', fontWeight: 400 }}>${(Number(item.quantity || 0) * Number(item.unit_price || 0)).toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', paddingTop: 16, marginTop: 12, borderTop: '1px solid var(--border)', flexShrink: 0 }}>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>Total</div>
<div style={{ fontSize: 24, fontWeight: 400, color: 'var(--accent)' }}>${Number(invoice.total || 0).toFixed(2)}</div>
</div>
</div>
</div>
{invoice.notes ? (
<div className="card" style={{ margin: '24px 0 0 0', flexShrink: 0 }}>
<div className="card-title">Notes</div>
<p style={{ fontSize: 14, color: 'var(--text-secondary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{invoice.notes}</p>
</div>
) : null}
</div>
</div>
);
}
export default function MyInvoices() { export default function MyInvoices() {
const { currentUser } = useAuth(); const { currentUser } = useAuth();
const companies = (currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : [])).slice().sort((a, b) => a.name.localeCompare(b.name)); const companyIds = (currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : []))
const [activeCompanyId, setActiveCompanyId] = useState(companies[0]?.id || null); .map(company => company?.id)
.filter(Boolean);
const [invoices, setInvoices] = useState([]); const [invoices, setInvoices] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [generatingInvoiceId, setGeneratingInvoiceId] = useState(''); const [activeInvoice, setActiveInvoice] = useState(null);
const { sortKey, sortDir, toggle, sort } = useSortable('invoice_date'); const [yearFilter, setYearFilter] = useState(String(new Date().getFullYear()));
const [companyFilter, setCompanyFilter] = useState('all');
const [filterMenuOpen, setFilterMenuOpen] = useState(false);
const filterMenuRef = useRef(null);
const { sortKey, sortDir, toggle, sort } = useSortable('invoice_date', 'desc');
useEffect(() => { useEffect(() => {
async function load() { async function load() {
if (companyIds.length === 0) {
setInvoices([]);
setLoading(false);
return;
}
const { data } = await supabase const { data } = await supabase
.from('invoices') .from('invoices')
.select('*, company:companies(name), items:invoice_items(*)') .select('*, company:companies(id, name), items:invoice_items(*)')
.in('company_id', companyIds)
.order('created_at', { ascending: false }); .order('created_at', { ascending: false });
setInvoices((data || []).filter(inv => inv.status !== 'draft')); setInvoices((data || []).filter(inv => inv.status !== 'draft'));
setLoading(false); setLoading(false);
} }
load(); load();
}, []); }, [currentUser?.id]); // eslint-disable-line react-hooks/exhaustive-deps
const handleDownload = async (invoice) => { useEffect(() => {
if (generatingInvoiceId) return; if (!filterMenuOpen) return;
setGeneratingInvoiceId(invoice.id); function onDocClick(e) {
try { if (filterMenuRef.current && !filterMenuRef.current.contains(e.target)) setFilterMenuOpen(false);
await generateInvoicePDF(invoice, invoice.company, invoice.items || []);
} finally {
setGeneratingInvoiceId('');
} }
}; document.addEventListener('mousedown', onDocClick);
return () => document.removeEventListener('mousedown', onDocClick);
const visible = companies.length > 1 && activeCompanyId }, [filterMenuOpen]);
? invoices.filter(inv => inv.company_id === activeCompanyId)
: invoices;
const outstanding = visible.filter(i => i.status === 'sent').reduce((s, i) => s + Number(i.total), 0);
const paid = visible.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total), 0);
const overdueCount = visible.filter(inv => inv.status !== 'paid' && new Date(inv.due_date) < new Date()).length;
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const chartYear = new Date().getFullYear(); const invoiceYears = [...new Set(invoices.map(i => i.invoice_date?.slice(0, 4)).filter(Boolean))].sort((a, b) => b.localeCompare(a));
const availableCompanyOptions = (currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : []))
.filter(company => company?.id && invoices.some(inv => inv.company_id === company.id))
.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
const chartYear = Number(yearFilter) || new Date().getFullYear();
const filteredInvoices = invoices.filter(inv => {
if (yearFilter !== 'all' && inv.invoice_date?.slice(0, 4) !== yearFilter) return false;
if (companyFilter !== 'all' && inv.company_id !== companyFilter) return false;
return true;
});
const chartData = useMemo(() => MONTHS.map((month, mi) => { const chartData = useMemo(() => MONTHS.map((month, mi) => {
const paidAmt = visible.filter(i => i.status === 'paid' && new Date(i.invoice_date).getFullYear() === chartYear && new Date(i.invoice_date).getMonth() === mi).reduce((s, i) => s + Number(i.total || 0), 0); const paidAmt = filteredInvoices.filter(i => i.status === 'paid' && new Date(i.invoice_date).getFullYear() === chartYear && new Date(i.invoice_date).getMonth() === mi).reduce((s, i) => s + Number(i.total || 0), 0);
const outAmt = visible.filter(i => i.status === 'sent' && new Date(i.invoice_date).getFullYear() === chartYear && new Date(i.invoice_date).getMonth() === mi).reduce((s, i) => s + Number(i.total || 0), 0); const outAmt = filteredInvoices.filter(i => i.status === 'sent' && new Date(i.invoice_date).getFullYear() === chartYear && new Date(i.invoice_date).getMonth() === mi).reduce((s, i) => s + Number(i.total || 0), 0);
return { month, Paid: +paidAmt.toFixed(2), Outstanding: +outAmt.toFixed(2) }; return { month, Paid: +paidAmt.toFixed(2), Outstanding: +outAmt.toFixed(2) };
}), [visible, chartYear]); }), [filteredInvoices, chartYear]);
const hasChartData = chartData.some(d => d.Paid > 0 || d.Outstanding > 0);
const sorted = sort(visible, (inv, key) => { const outstanding = filteredInvoices.filter(i => i.status === 'sent').reduce((s, i) => s + Number(i.total || 0), 0);
const paid = filteredInvoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total || 0), 0);
const overdueCount = filteredInvoices.filter(inv => inv.status !== 'paid' && inv.due_date && new Date(inv.due_date) < new Date()).length;
const paidCount = filteredInvoices.filter(i => i.status === 'paid').length;
const sortedInvoices = sort(filteredInvoices, (inv, key) => {
if (key === 'invoice_date' || key === 'due_date') return new Date(inv[key] || 0).getTime(); if (key === 'invoice_date' || key === 'due_date') return new Date(inv[key] || 0).getTime();
if (key === 'total') return Number(inv.total || 0); if (key === 'total') return Number(inv.total || 0);
if (key === 'company') return inv.company?.name || '';
return inv[key] || ''; return inv[key] || '';
}); });
const th = { sortKey, sortDir, onSort: toggle };
return ( return (
<Layout> <Layout loading={loading}>
<div className="page-header"> <div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div> <div style={{ display: 'grid', gridTemplateColumns: '3fr 2fr', gap: 24, flexShrink: 0 }}>
<div className="page-title">Invoices</div> <div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' }}>
<div className="page-subtitle">{visible.length} invoice{visible.length !== 1 ? 's' : ''}</div> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Invoice Overview</span>
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{chartYear}</span>
</div> </div>
</div> <ResponsiveContainer width="100%" height={160}>
{hasChartData && (
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '16px 16px 8px', marginBottom: 18 }}>
<ResponsiveContainer width="100%" height={180}>
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}> <AreaChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
<defs> <defs>
<linearGradient id="gradPaidC" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#4ade80" stopOpacity={0.25}/><stop offset="95%" stopColor="#4ade80" stopOpacity={0}/></linearGradient> <linearGradient id="clientPaidGrad" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#4ade80" stopOpacity={0.25}/><stop offset="95%" stopColor="#4ade80" stopOpacity={0}/></linearGradient>
<linearGradient id="gradOutC" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#60a5fa" stopOpacity={0.2}/><stop offset="95%" stopColor="#60a5fa" stopOpacity={0}/></linearGradient> <linearGradient id="clientOutGrad" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#60a5fa" stopOpacity={0.2}/><stop offset="95%" stopColor="#60a5fa" stopOpacity={0}/></linearGradient>
</defs> </defs>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} /> <CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} />
<XAxis dataKey="month" tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} /> <XAxis dataKey="month" tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} tickFormatter={v => `$${v >= 1000 ? (v / 1000).toFixed(0) + 'k' : v}`} width={45} /> <YAxis tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} tickFormatter={v => `$${v >= 1000 ? (v / 1000).toFixed(0) + 'k' : v}`} width={45} />
<Tooltip formatter={(v) => [`$${Number(v).toLocaleString('en-US', { minimumFractionDigits: 2 })}`, undefined]} contentStyle={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, fontSize: 12 }} /> <Tooltip content={<ClientFinanceTooltip year={chartYear} />} />
<Legend wrapperStyle={{ fontSize: 11, paddingTop: 8 }} /> <Legend wrapperStyle={{ fontSize: 11, paddingTop: 8 }} />
<Area type="monotone" dataKey="Paid" stroke="#4ade80" strokeWidth={2} fill="url(#gradPaidC)" dot={false} activeDot={{ r: 4 }} /> <Area type="monotone" dataKey="Paid" stroke="#4ade80" strokeWidth={2} fill="url(#clientPaidGrad)" dot={false} activeDot={{ r: 4 }} />
<Area type="monotone" dataKey="Outstanding" stroke="#60a5fa" strokeWidth={2} fill="url(#gradOutC)" dot={false} activeDot={{ r: 4 }} /> <Area type="monotone" dataKey="Outstanding" stroke="#60a5fa" strokeWidth={2} fill="url(#clientOutGrad)" dot={false} activeDot={{ r: 4 }} />
</AreaChart> </AreaChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
)}
{companies.length > 1 && ( <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, alignContent: 'start' }}>
<div style={{ marginBottom: 16 }}> <ClientFinanceStatCard label="Paid" value={`$${paid.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`} sub={`${paidCount} settled`} iconBg="rgba(74,222,128,0.15)" iconColor="#4ade80" iconPath={'<polyline points="4,12 9,17 20,6"/>'} />
<select className="filter-select" value={activeCompanyId || ''} onChange={e => setActiveCompanyId(e.target.value)}> <ClientFinanceStatCard label="Outstanding" value={`$${outstanding.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`} sub="ready to pay" iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={'<circle cx="12" cy="12" r="8"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>'} />
{companies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)} <ClientFinanceStatCard label="Overdue" value={overdueCount} sub={overdueCount === 1 ? 'needs attention' : 'need attention'} iconBg="rgba(239,68,68,0.15)" iconColor="#ef4444" iconPath={'<path d="M12 9v4"/><path d="M12 16h.01"/><path d="M10.29 3.86l-7.5 13A1 1 0 0 0 3.66 18h16.68a1 1 0 0 0 .87-1.5l-7.5-13a1 1 0 0 0-1.74 0z"/>'} />
</select> <ClientFinanceStatCard label="Invoices" value={invoices.length} sub={`${companyIds.length} companies`} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconPath={'<rect x="3" y="4" width="18" height="16" rx="2"/><line x1="7" y1="9" x2="17" y2="9"/><line x1="7" y1="13" x2="17" y2="13"/>'} />
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 24, marginBottom: 10, flexShrink: 0, minHeight: 'var(--btn-height)' }}>
<div style={{ marginLeft: 'auto', position: 'relative', display: 'flex', alignItems: 'center' }} ref={filterMenuRef}>
<button className="btn btn-outline" onClick={() => setFilterMenuOpen(o => !o)} style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }} title="Filter invoices">
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M2 4h12M4 8h8M6 12h4" /></svg>
</button>
{filterMenuOpen && (
<div className="site-header-avatar-menu" style={{ position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 220, minWidth: 160 }}>
<div style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-muted)', padding: '6px 14px 4px' }}>Year</div>
<button onClick={() => { setYearFilter('all'); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: yearFilter === 'all' ? 'rgba(245,165,35,0.08)' : 'transparent', color: yearFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Years</button>
{invoiceYears.map(year => (
<button key={year} onClick={() => { setYearFilter(year); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: yearFilter === year ? 'rgba(245,165,35,0.08)' : 'transparent', color: yearFilter === year ? 'var(--accent)' : 'var(--text-primary)' }}>{year}</button>
))}
{availableCompanyOptions.length > 1 && (
<>
<div style={{ borderTop: '1px solid var(--border)', margin: '4px 0' }} />
<div style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-muted)', padding: '4px 14px 4px' }}>Company</div>
<button onClick={() => { setCompanyFilter('all'); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: companyFilter === 'all' ? 'rgba(245,165,35,0.08)' : 'transparent', color: companyFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Companies</button>
{availableCompanyOptions.map(company => (
<button key={company.id} onClick={() => { setCompanyFilter(company.id); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: companyFilter === company.id ? 'rgba(245,165,35,0.08)' : 'transparent', color: companyFilter === company.id ? 'var(--accent)' : 'var(--text-primary)' }}>{company.name}</button>
))}
</>
)}
</div> </div>
)} )}
{loading ? (
<p style={{ color: 'var(--text-muted)' }}>Loading...</p>
) : visible.length === 0 ? (
<div className="empty-state">
<h3>No invoices yet</h3>
<p>Your invoices will appear here once they are sent.</p>
</div> </div>
</div>
<div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
{filteredInvoices.length === 0 ? (
<div className="card-empty-center">No invoices</div>
) : ( ) : (
<div className="card"> <div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<div className="table-wrapper"> <table className="table-sticky-head table-no-row-hover" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<table> <colgroup>
<col style={{ width: '15%' }} />
<col style={{ width: '20%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '15%' }} />
</colgroup>
<thead> <thead>
<tr> <tr>
<SortTh col="invoice_number" {...th}>Invoice #</SortTh> <SortTh col="invoice_number" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Invoice #</SortTh>
<SortTh col="invoice_date" {...th}>Issued</SortTh> <SortTh col="company" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Company</SortTh>
<SortTh col="due_date" {...th}>Due</SortTh> <SortTh col="invoice_date" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Issued</SortTh>
<SortTh col="status" {...th}>Status</SortTh> <SortTh col="due_date" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Due</SortTh>
<SortTh col="total" {...th}>Total</SortTh> <SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Status</SortTh>
<th></th> <SortTh col="total" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Total</SortTh>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{sorted.map(inv => { {sortedInvoices.map(inv => {
const isOverdue = inv.status !== 'paid' && new Date(inv.due_date) < new Date(); const isOverdue = inv.status !== 'paid' && inv.due_date && new Date(inv.due_date) < new Date();
return ( return (
<tr key={inv.id}> <tr key={inv.id}>
<td style={{ fontWeight: 400 }}>{inv.invoice_number}</td> <td style={{ padding: '5px 0' }}>
<td style={{ color: 'var(--text-muted)' }}>{new Date(inv.invoice_date).toLocaleDateString()}</td> <button type="button" className="table-link" onClick={() => setActiveInvoice(inv)}>{inv.invoice_number}</button>
<td style={{ color: isOverdue ? 'var(--danger)' : 'var(--text-muted)' }}>
{inv.due_date ? new Date(inv.due_date).toLocaleDateString() : '—'}
{isOverdue && <span style={{ marginLeft: 6, fontSize: 11 }}>Overdue</span>}
</td> </td>
<td> <td style={{ padding: '5px 0', fontSize: 13 }}>
<StatusBadge {inv.company?.id ? (
status={statusColor[inv.status] || 'not_started'} <Link to={`/company/${inv.company.id}`} className="table-link">
label={inv.status ? inv.status.charAt(0).toUpperCase() + inv.status.slice(1) : '—'} {inv.company?.name || inv.bill_to || '—'}
/> </Link>
) : (
inv.company?.name || inv.bill_to || '—'
)}
</td> </td>
<td style={{ fontWeight: 400, color: 'var(--accent)' }}>${Number(inv.total).toFixed(2)}</td> <td style={{ padding: '5px 0', fontSize: 12, color: 'var(--text-muted)' }}>{inv.invoice_date ? new Date(inv.invoice_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</td>
<td> <td style={{ padding: '5px 0', fontSize: 12, color: isOverdue ? 'var(--danger)' : 'var(--text-muted)' }}>{inv.due_date ? new Date(inv.due_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</td>
<LoadingButton <td style={{ padding: '5px 0' }}>
className="btn btn-outline btn-sm" <StatusBadge status={statusColor[inv.status] || 'not_started'} label={invoiceStatusLabel(inv.status)} />
loading={generatingInvoiceId === inv.id}
disabled={Boolean(generatingInvoiceId)}
loadingText="Generating..."
onClick={() => handleDownload(inv)}
>
Download PDF
</LoadingButton>
</td> </td>
<td style={{ padding: '5px 0', textAlign: 'right', fontSize: 13, color: inv.status === 'paid' ? '#4ade80' : inv.status === 'sent' ? '#F5A523' : 'var(--text-primary)', fontWeight: 400 }}>${Number(inv.total || 0).toFixed(2)}</td>
</tr> </tr>
); );
})} })}
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
)} )}
</div>
</div>
</div>
<ClientInvoiceModal invoice={activeInvoice} onClose={() => setActiveInvoice(null)} />
</Layout> </Layout>
); );
} }
+2 -288
View File
@@ -1,296 +1,10 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout'; import Layout from '../../components/Layout';
import LoadingButton from '../../components/LoadingButton'; import SubcontractorInvoiceForm from '../../components/SubcontractorInvoiceForm';
import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext';
import { sendEmail } from '../../lib/email';
const INVOICE_TODAY = new Date().toISOString().split('T')[0];
function newItem(description = '', unit_price = '', quantity = 1, taskId = null, isRevision = false) {
return { id: crypto.randomUUID(), description, unit_price, quantity, task_id: taskId, is_revision: isRevision };
}
function genNumber(count) {
const year = new Date().getFullYear();
return `INVSUB-${year}-${String(count + 1).padStart(3, '0')}`;
}
export default function MyInvoiceCreate() { export default function MyInvoiceCreate() {
const navigate = useNavigate();
const { currentUser } = useAuth();
const [invoiceNumber, setInvoiceNumber] = useState('');
const [completedTasks, setCompletedTasks] = useState([]);
const [loadingTasks, setLoadingTasks] = useState(true);
const [items, setItems] = useState([newItem()]);
const [notes, setNotes] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const dragItem = useRef(null);
const rate = Number(currentUser?.brand_book_rate || 60);
useEffect(() => {
async function load() {
const [{ data: tasks }, { data: existingInvoices }, { data: nextNum }] = await Promise.all([
supabase
.from('tasks')
.select('id, title, current_version, project:projects(name)')
.eq('status', 'client_approved')
.eq('assigned_to', currentUser.id)
.order('title'),
supabase
.from('subcontractor_invoices')
.select('id, status, items:subcontractor_invoice_items(task_id)')
.in('status', ['submitted', 'paid']),
supabase.rpc('get_next_sub_invoice_number'),
]);
setInvoiceNumber(nextNum || genNumber(0));
const invoicedTaskIds = new Set(
(existingInvoices || []).flatMap(inv => (inv.items || []).map(i => i.task_id).filter(Boolean))
);
setCompletedTasks((tasks || []).filter(t => !invoicedTaskIds.has(t.id)));
setLoadingTasks(false);
}
load();
}, []);
const addedTaskIds = useMemo(() => new Set(items.map(i => i.task_id).filter(Boolean)), [items]);
const addTask = (task) => {
if (addedTaskIds.has(task.id)) return;
const version = task.current_version || 0;
const baseDesc = task.project?.name ? `${task.project.name}${task.title}` : task.title;
const toAdd = [newItem(`${baseDesc} R00`, rate, 1, task.id, false)];
for (let v = 1; v <= version; v++) {
toAdd.push(newItem(`${baseDesc} R${String(v).padStart(2, '0')}`, 30, 1, task.id, true));
}
setItems(prev => {
if (prev.length === 1 && !prev[0].description && !prev[0].unit_price) return toAdd;
return [...prev, ...toAdd];
});
};
const updateItem = (id, field, value) => {
setItems(prev => prev.map(item => item.id === id ? { ...item, [field]: value } : item));
};
const removeItem = (id) => setItems(prev => prev.filter(item => item.id !== id));
const handleDrop = (targetIndex) => {
if (dragItem.current === null || dragItem.current === targetIndex) { dragItem.current = null; return; }
setItems(prev => {
const next = [...prev];
const [moved] = next.splice(dragItem.current, 1);
next.splice(targetIndex, 0, moved);
return next;
});
dragItem.current = null;
};
const total = items.reduce((s, i) => s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0), 0);
const handleSubmit = async () => {
const valid = items.filter(i => String(i.description).trim());
if (!valid.length) { setError('Add at least one line item.'); return; }
setSaving(true);
setError('');
try {
const { data: inv, error: invErr } = await supabase
.from('subcontractor_invoices')
.insert({
profile_id: currentUser.id,
invoice_number: invoiceNumber.trim(),
status: 'submitted',
notes: notes.trim(),
submitted_at: new Date().toISOString(),
})
.select()
.single();
if (invErr) throw invErr;
const { error: itemsErr } = await supabase.from('subcontractor_invoice_items').insert(
valid.map((item, idx) => ({
invoice_id: inv.id,
task_id: item.task_id || null,
description: String(item.description).trim(),
quantity: Number(item.quantity) || 1,
unit_price: Number(item.unit_price) || 0,
sort_order: idx,
}))
);
if (itemsErr) throw itemsErr;
const total = valid.reduce((s, i) => s + (Number(i.unit_price) || 0) * (Number(i.quantity) || 1), 0);
sendEmail('subcontractor_invoice_submitted', 'hello@fourgebranding.com', {
subName: currentUser.name,
invoiceNumber: inv.invoice_number,
total: total.toFixed(2),
invoiceId: inv.id,
}).catch(err => console.error('Sub invoice notification failed:', err));
navigate(`/my-invoices-sub/${inv.id}`);
} catch (err) {
setError(err.message);
setSaving(false);
}
};
return ( return (
<Layout> <Layout>
<button className="back-link" onClick={() => navigate('/my-invoices-sub')}> Back to Invoices</button> <SubcontractorInvoiceForm />
<div className="page-header">
<div>
<div className="page-title">New Invoice</div>
<div className="page-subtitle">
Invoice date: {new Date(INVOICE_TODAY).toLocaleDateString()} · {invoiceNumber}
</div>
</div>
</div>
{error && <div className="notification notification-info" style={{ marginBottom: 16 }}>{error}</div>}
<div className="card" style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<div className="card-title" style={{ marginBottom: 0 }}>Completed Tasks</div>
{completedTasks.length > 0 && !loadingTasks && (
<button
className="btn btn-outline btn-sm"
onClick={() => completedTasks.forEach(task => { if (!addedTaskIds.has(task.id)) addTask(task); })}
>
+ Add All
</button>
)}
</div>
{loadingTasks ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading...</p>
) : completedTasks.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>No completed tasks available to invoice.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{completedTasks.map(task => {
const alreadyAdded = addedTaskIds.has(task.id);
return (
<div key={task.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 400 }}>
{task.project?.name ? `${task.project.name}` : ''}{task.title}
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
{(() => {
const v = task.current_version || 0;
const parts = [`R00 New Book $${rate.toFixed(2)}`];
if (v > 0) parts.push(`+ ${v} Revision${v > 1 ? 's' : ''} @ $30ea`);
return parts.join(' · ');
})()}
</div>
</div>
<button
className={`btn btn-sm ${alreadyAdded ? 'btn-outline' : 'btn-primary'}`}
onClick={() => !alreadyAdded && addTask(task)}
disabled={alreadyAdded}
>
{alreadyAdded ? 'Added' : '+ Add'}
</button>
</div>
);
})}
</div>
)}
</div>
<div className="card" style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<div className="card-title" style={{ marginBottom: 0 }}>Line Items</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 80px 120px 120px 40px', gap: 8, marginBottom: 8 }}>
{['', 'Type', 'Description', 'Qty / Hrs', 'Rate', 'Total', ''].map((h, i) => (
<div key={i} style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', textAlign: i > 3 ? 'right' : 'left' }}>{h}</div>
))}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{items.map((item, index) => (
<div
key={item.id}
onDragOver={e => e.preventDefault()}
onDrop={() => handleDrop(index)}
style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 80px 120px 120px 40px', gap: 8, alignItems: 'center' }}
>
<div
draggable
onDragStart={e => { dragItem.current = index; e.dataTransfer.effectAllowed = 'move'; }}
style={{ cursor: 'grab', color: 'var(--text-muted)', fontSize: 14, textAlign: 'center', userSelect: 'none' }}
></div>
<span className={`badge ${item.is_revision ? 'badge-client_revision' : 'badge-initial'}`}>
{item.is_revision ? 'Revision' : 'New'}
</span>
<input
type="text"
placeholder="Description..."
value={item.description}
onChange={e => updateItem(item.id, 'description', e.target.value)}
style={{ margin: 0 }}
/>
<input
type="number"
min="0.5"
step="0.5"
value={item.quantity}
onChange={e => updateItem(item.id, 'quantity', e.target.value)}
style={{ margin: 0, textAlign: 'center' }}
/>
<input
type="number"
min="0"
step="0.01"
placeholder="0.00"
value={item.unit_price}
onChange={e => updateItem(item.id, 'unit_price', e.target.value)}
style={{ margin: 0, textAlign: 'right' }}
/>
<div style={{ textAlign: 'right', fontSize: 14, fontWeight: 400, color: 'var(--text-primary)', paddingRight: 4 }}>
${((Number(item.quantity) || 0) * (Number(item.unit_price) || 0)).toFixed(2)}
</div>
<button onClick={() => removeItem(item.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--danger)', fontSize: 16, padding: 4 }}></button>
</div>
))}
</div>
<button className="btn btn-outline btn-sm" style={{ marginTop: 12 }} onClick={() => setItems(prev => [...prev, newItem()])}>
+ Add Line Item
</button>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 20, paddingTop: 16, borderTop: '1px solid var(--border)' }}>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>Total</div>
<div style={{ fontSize: 26, fontWeight: 400, color: 'var(--accent)' }}>${total.toFixed(2)}</div>
</div>
</div>
</div>
<div className="card" style={{ marginBottom: 24 }}>
<div className="card-title">Notes</div>
<textarea
placeholder="Additional notes for the team..."
value={notes}
onChange={e => setNotes(e.target.value)}
style={{ minHeight: 80 }}
/>
</div>
<div className="action-buttons">
<LoadingButton className="btn btn-primary" onClick={handleSubmit} loading={saving} loadingText="Submitting...">
Submit Invoice
</LoadingButton>
<button className="btn btn-outline" onClick={() => navigate('/my-invoices-sub')} disabled={saving}>Cancel</button>
</div>
</Layout> </Layout>
); );
} }
+60 -182
View File
@@ -1,34 +1,27 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import Layout from '../../components/Layout'; import Layout from '../../components/Layout';
import PageLoader from '../../components/PageLoader';
import LoadingButton from '../../components/LoadingButton'; import LoadingButton from '../../components/LoadingButton';
import SortTh from '../../components/SortTh';
import StatusBadge from '../../components/StatusBadge';
import { supabase } from '../../lib/supabase'; import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/AuthContext';
import { generateSubcontractorPOPDF } from '../../lib/invoice'; import { generateSubcontractorPOPDF } from '../../lib/invoice';
import { useSortable } from '../../hooks/useSortable'; import { useSortable } from '../../hooks/useSortable';
import SubcontractorInvoiceDetailView from '../../components/SubcontractorInvoiceDetailView';
const STATUS_COLOR = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
const STATUS_LABEL = { draft: 'Draft', submitted: 'Submitted', paid: 'Paid' }; const STATUS_LABEL = { draft: 'Draft', submitted: 'Submitted', paid: 'Paid' };
function fmt(val) {
return `$${Number(val || 0).toFixed(2)}`;
}
function invoiceTotal(items) { function invoiceTotal(items) {
return (items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0); return (items || []).reduce((sum, item) => sum + Number(item.unit_price || 0) * Number(item.quantity || 1), 0);
} }
export default function MyInvoiceDetail() { export default function MyInvoiceDetail() {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate();
const { currentUser } = useAuth(); const { currentUser } = useAuth();
const [invoice, setInvoice] = useState(null); const [invoice, setInvoice] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [deleting, setDeleting] = useState(false);
const [downloading, setDownloading] = useState(false); const [downloading, setDownloading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const { sortKey, sortDir, toggle, sort } = useSortable('description'); const { sortKey, sortDir, toggle, sort } = useSortable('description');
@@ -40,7 +33,11 @@ export default function MyInvoiceDetail() {
.select('*, items:subcontractor_invoice_items(*)') .select('*, items:subcontractor_invoice_items(*)')
.eq('id', id) .eq('id', id)
.single(); .single();
if (err || !data) { setError('Invoice not found.'); setLoading(false); return; } if (err || !data) {
setError('Invoice not found.');
setLoading(false);
return;
}
setInvoice(data); setInvoice(data);
setLoading(false); setLoading(false);
} }
@@ -49,24 +46,18 @@ export default function MyInvoiceDetail() {
async function handleSubmit() { async function handleSubmit() {
setSubmitting(true); setSubmitting(true);
const submittedAt = new Date().toISOString();
const { error: err } = await supabase const { error: err } = await supabase
.from('subcontractor_invoices') .from('subcontractor_invoices')
.update({ status: 'submitted', submitted_at: new Date().toISOString() }) .update({ status: 'submitted', submitted_at: submittedAt })
.eq('id', id); .eq('id', id);
if (err) setError(err.message); if (err) setError(err.message);
else setInvoice(i => ({ ...i, status: 'submitted', submitted_at: new Date().toISOString() })); else setInvoice((current) => ({ ...current, status: 'submitted', submitted_at: submittedAt }));
setSubmitting(false); setSubmitting(false);
} }
async function handleDelete() {
if (!window.confirm(`Delete invoice ${invoice.invoice_number}? This cannot be undone.`)) return;
setDeleting(true);
const { error: err } = await supabase.from('subcontractor_invoices').delete().eq('id', id);
if (err) { setError(err.message); setDeleting(false); return; }
navigate('/my-invoices-sub');
}
async function handleDownload() { async function handleDownload() {
if (!invoice) return;
setDownloading(true); setDownloading(true);
try { try {
const total = invoiceTotal(invoice.items); const total = invoiceTotal(invoice.items);
@@ -92,13 +83,16 @@ export default function MyInvoiceDetail() {
setDownloading(false); setDownloading(false);
} }
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>; if (loading) return <Layout><PageLoader /></Layout>;
if (!invoice) return <Layout><p style={{ padding: 24 }}>{error || 'Invoice not found.'}</p></Layout>; if (!invoice) return <Layout><p style={{ padding: 24 }}>{error || 'Invoice not found.'}</p></Layout>;
const total = invoiceTotal(invoice.items); const total = invoiceTotal(invoice.items);
const sortedItems = [...(invoice.items || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)); const sortedItems = [...(invoice.items || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
const tableItems = sort(sortedItems, (item, key) => { const tableItems = sort(sortedItems, (item, key) => {
if (key === 'type') return item.task_id ? 'Task' : 'Other'; const typeSort = /\bR(\d{2})\b/i.test(String(item.description || ''))
? Number(String(item.description).match(/\bR(\d{2})\b/i)?.[1] || 0)
: item.task_id ? 0 : 999;
if (key === 'type') return typeSort;
if (key === 'description') return item.description || ''; if (key === 'description') return item.description || '';
if (key === 'quantity') return Number(item.quantity || 0); if (key === 'quantity') return Number(item.quantity || 0);
if (key === 'unit_price') return Number(item.unit_price || 0); if (key === 'unit_price') return Number(item.unit_price || 0);
@@ -106,175 +100,59 @@ export default function MyInvoiceDetail() {
return ''; return '';
}); });
return ( const headerActions = (
<Layout> <>
<button className="back-link" onClick={() => navigate('/my-invoices-sub')}> Back to Invoices</button> {invoice.status === 'draft' ? (
<LoadingButton className="btn btn-outline" loading={submitting} loadingText="Submitting…" disabled={submitting} onClick={handleSubmit}>
<div className="page-header"> Submit to Team
<div> </LoadingButton>
<div className="page-title">{invoice.invoice_number}</div> ) : null}
<div className="page-subtitle">Fourge Branding</div> {invoice.status === 'paid' ? (
</div> <LoadingButton className="btn btn-outline" loading={downloading} loadingText="Generating…" disabled={downloading} onClick={handleDownload}>
<div className="action-buttons">
<StatusBadge
status={STATUS_COLOR[invoice.status] || 'not_started'}
label={STATUS_LABEL[invoice.status] || invoice.status}
/>
{invoice.status === 'paid' && (
<LoadingButton
className="btn btn-primary"
loading={downloading}
loadingText="Generating PDF..."
disabled={downloading}
onClick={handleDownload}
>
Download Receipt Download Receipt
</LoadingButton> </LoadingButton>
)} ) : null}
</div> </>
</div> );
{error && <div className="notification notification-info" style={{ marginBottom: 16 }}>{error}</div>} return (
<Layout>
<div className="grid-2" style={{ marginBottom: 24 }}> <SubcontractorInvoiceDetailView
<div className="card"> error={error}
<div className="card-title">From</div> headerActions={headerActions}
leftCardTitle="From"
leftCardBody={(
<>
<div style={{ fontSize: 15, fontWeight: 400 }}>{currentUser?.name || 'Subcontractor'}</div> <div style={{ fontSize: 15, fontWeight: 400 }}>{currentUser?.name || 'Subcontractor'}</div>
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 4 }}>{currentUser?.email}</div> <div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 4 }}>{currentUser?.email || '—'}</div>
</div> </>
<div className="card">
<div className="card-title">Invoice Details</div>
<div className="detail-grid" style={{ marginBottom: 0 }}>
<div className="detail-item">
<label>Invoice #</label>
<p style={{ fontWeight: 400 }}>{invoice.invoice_number}</p>
</div>
<div className="detail-item">
<label>Created</label>
<p>{new Date(invoice.created_at).toLocaleDateString()}</p>
</div>
<div className="detail-item">
<label>Terms</label>
<p>NET 30 from client payment</p>
</div>
<div className="detail-item">
<label>Status</label>
<p>
<StatusBadge
status={STATUS_COLOR[invoice.status] || 'not_started'}
label={STATUS_LABEL[invoice.status] || invoice.status}
/>
</p>
</div>
{invoice.submitted_at && (
<div className="detail-item">
<label>Submitted</label>
<p>{new Date(invoice.submitted_at).toLocaleDateString()}</p>
</div>
)} )}
<div className="detail-item"> rightCardTitle="Invoice Details"
<label>Total</label> rightCardBody={(
<p style={{ fontSize: 18, fontWeight: 400, color: 'var(--accent)' }}>{fmt(total)}</p> <div className="invoice-detail-meta-grid">
</div> <div className="invoice-detail-meta-item"><label>Invoice #</label><p>{invoice.invoice_number}</p></div>
{invoice.paid_at && ( <div className="invoice-detail-meta-item"><label>Created</label><p>{new Date(invoice.created_at).toLocaleDateString()}</p></div>
<div className="detail-item"> <div className="invoice-detail-meta-item"><label>Terms</label><p>NET 30 from client payment</p></div>
<div className="invoice-detail-meta-item"><label>Status</label><p>{STATUS_LABEL[invoice.status] || invoice.status}</p></div>
{invoice.submitted_at ? <div className="invoice-detail-meta-item"><label>Submitted</label><p>{new Date(invoice.submitted_at).toLocaleDateString()}</p></div> : null}
<div className="invoice-detail-meta-item"><label>Total</label><p style={{ fontSize: 18, color: 'var(--accent)' }}>${total.toFixed(2)}</p></div>
{invoice.paid_at ? (
<div className="invoice-detail-meta-item">
<label>Paid On</label> <label>Paid On</label>
<p style={{ color: 'var(--success, #16a34a)', fontWeight: 400 }}> <p style={{ color: 'var(--success, #16a34a)' }}>
{new Date(invoice.paid_at).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })} {new Date(invoice.paid_at).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
</p> </p>
</div> </div>
)} ) : null}
</div>
</div>
</div>
<div className="card" style={{ marginBottom: 24 }}>
<div className="card-title">Line Items</div>
<div className="table-wrapper" style={{ border: 'none' }}>
<table>
<thead>
<tr>
<SortTh col="type" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ width: 100 }}>Type</SortTh>
<SortTh col="description" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Description</SortTh>
<SortTh col="quantity" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'center' }}>Qty / Hrs</SortTh>
<SortTh col="unit_price" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Rate</SortTh>
<SortTh col="line_total" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Total</SortTh>
</tr>
</thead>
<tbody>
{tableItems.map(item => (
<tr key={item.id}>
<td>
<span className={`badge ${item.task_id ? 'badge-in_progress' : 'badge-initial'}`}>
{item.task_id ? 'Task' : 'Other'}
</span>
</td>
<td>{item.description}</td>
<td style={{ textAlign: 'center' }}>{item.quantity}</td>
<td style={{ textAlign: 'right' }}>{fmt(item.unit_price)}</td>
<td style={{ textAlign: 'right', fontWeight: 400 }}>
{fmt(Number(item.unit_price) * Number(item.quantity || 1))}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '16px 16px 0', borderTop: '1px solid var(--border)', marginTop: 8 }}>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>Total</div>
<div style={{ fontSize: 24, fontWeight: 400, color: 'var(--accent)' }}>{fmt(total)}</div>
</div>
</div>
</div>
{invoice.notes && (
<div className="card" style={{ marginBottom: 24 }}>
<div className="card-title">Notes</div>
<p style={{ fontSize: 14, color: 'var(--text-secondary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{invoice.notes}</p>
</div> </div>
)} )}
sortKey={sortKey}
<div className="card"> sortDir={sortDir}
<div className="card-title">Actions</div> onSort={toggle}
<div className="action-buttons"> items={tableItems}
{invoice.status === 'draft' && ( notes={invoice.notes}
<LoadingButton total={total}
className="btn btn-primary" />
loading={submitting}
loadingText="Submitting..."
disabled={submitting || deleting}
onClick={handleSubmit}
>
Submit to Team
</LoadingButton>
)}
{invoice.status === 'paid' && (
<LoadingButton
className="btn btn-success"
loading={downloading}
loadingText="Generating PDF..."
disabled={downloading}
onClick={handleDownload}
>
Download Receipt
</LoadingButton>
)}
{invoice.status !== 'paid' && (
<LoadingButton
className="btn-icon btn-icon-danger"
loading={deleting}
loadingText="..."
disabled={submitting || deleting}
title="Delete Invoice"
onClick={handleDelete}
>
</LoadingButton>
)}
</div>
</div>
</Layout> </Layout>
); );
} }
+503 -78
View File
@@ -1,16 +1,27 @@
import { useEffect, useState, useMemo } from 'react'; import { useEffect, useState, useMemo } from 'react';
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import { useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout'; import Layout from '../../components/Layout';
import StatusBadge from '../../components/StatusBadge'; import StatusBadge from '../../components/StatusBadge';
import SortTh from '../../components/SortTh'; import SortTh from '../../components/SortTh';
import LoadingButton from '../../components/LoadingButton';
import { supabase } from '../../lib/supabase'; import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/AuthContext';
import { readPageCache, writePageCache } from '../../lib/pageCache'; import { readPageCache, writePageCache } from '../../lib/pageCache';
import { useSortable } from '../../hooks/useSortable'; import { useSortable } from '../../hooks/useSortable';
import { isCompletedVersionEligible } from '../../lib/invoiceVersionRules';
import { popupOverlayStyle, popupSurfaceStyle } from '../../lib/popupStyles';
import SubcontractorInvoiceForm from '../../components/SubcontractorInvoiceForm';
import { generateSubcontractorPOPDF } from '../../lib/invoice';
const STATUS_BADGE = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' }; const STATUS_BADGE = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
const STATUS_LABEL = { draft: 'Draft', submitted: 'Submitted', paid: 'Paid' }; const STATUS_LABEL = { draft: 'Draft', submitted: 'Submitted', paid: 'Paid' };
const FIELD_LABEL_STYLE = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, display: 'block', marginBottom: 4 };
const STAT_ICONS = {
newTasks: '<rect x="3" y="4" width="18" height="16" rx="2"/><line x1="7" y1="9" x2="17" y2="9"/><line x1="7" y1="13" x2="17" y2="13"/>',
revisions: '<path d="M4 12a8 8 0 018-8v0a8 8 0 018 8"/><polyline points="18,8 20,12 16,12"/>',
invoiced: '<path d="M5 3h11l3 3v15H5z"/><path d="M16 3v4h4"/><line x1="8" y1="11" x2="16" y2="11"/><line x1="8" y1="15" x2="16" y2="15"/>',
paid: '<polyline points="4,12 9,17 20,6"/>',
};
function fmt(val) { function fmt(val) {
return `$${Number(val || 0).toFixed(2)}`; return `$${Number(val || 0).toFixed(2)}`;
@@ -20,115 +31,388 @@ function invoiceTotal(items) {
return (items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0); return (items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
} }
function TaskStatCard({ label, value, sub, iconBg, iconColor, iconPath }) {
return (
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', minHeight: 120 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
<div style={{ width: 27, height: 27, borderRadius: '50%', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={iconColor} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" dangerouslySetInnerHTML={{ __html: iconPath }} />
</div>
</div>
<div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{value}</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 5 }}>{sub}</div>
</div>
);
}
function asArray(value) {
if (Array.isArray(value)) return value;
if (value == null) return [];
return typeof value === 'object' ? [value] : [];
}
function parseItemVersionNumber(item) {
if (Number.isFinite(Number(item?.version_number))) return Number(item.version_number);
const match = String(item?.description || '').match(/\bR(\d{2})\b/i);
return match ? Number(match[1]) : 0;
}
function itemVersionLabel(item) {
return `R${String(parseItemVersionNumber(item)).padStart(2, '0')}`;
}
function itemWorkLabel(item) {
return parseItemVersionNumber(item) > 0 ? 'Revision' : 'New';
}
function cleanItemDescription(item) {
return String(item?.description || '—').replace(/\s*[-]\s*R\d{2}\b/i, '').trim() || '—';
}
async function fetchTaskTypeMap(taskIds) {
const uniqueTaskIds = [...new Set((taskIds || []).filter(Boolean))];
if (uniqueTaskIds.length === 0) return {};
const { data, error } = await supabase
.from('tasks')
.select('id, title, submissions(service_type, type)')
.in('id', uniqueTaskIds);
if (error) throw error;
const next = {};
for (const task of (data || [])) {
const submissions = asArray(task.submissions);
const initial = submissions.find((submission) => submission?.type === 'initial' && submission?.service_type);
const fallback = submissions.find((submission) => submission?.service_type);
next[task.id] = initial?.service_type || fallback?.service_type || task.title || 'Other';
}
return next;
}
function buildPDFArgs(invoice, profile, total, statusOverride) {
return {
po_number: invoice.invoice_number,
status: statusOverride || invoice.status || 'draft',
profile,
project: { name: 'Subcontractor Invoice', company: { name: 'Fourge Branding' } },
date: invoice.created_at?.split('T')[0],
due_date: invoice.paid_at?.split('T')[0] || invoice.submitted_at?.split('T')[0] || invoice.created_at?.split('T')[0],
amount: total,
description: invoice.status === 'paid'
? 'Payment received for completed subcontractor work.'
: 'Subcontractor invoice for completed work.',
notes: invoice.notes,
items: (invoice.items || []).map((item, idx) => ({
description: item.description,
amount: Number(item.unit_price || 0) * Number(item.quantity || 1),
sort_order: item.sort_order ?? idx,
})),
};
}
export default function MyInvoices() { export default function MyInvoices() {
const { currentUser } = useAuth(); const { currentUser } = useAuth();
const navigate = useNavigate();
const cacheKey = `ext-invoices:${currentUser?.id}`; const cacheKey = `ext-invoices:${currentUser?.id}`;
const cached = readPageCache(cacheKey, 3 * 60_000); const cached = readPageCache(cacheKey, 3 * 60_000);
const [invoices, setInvoices] = useState(() => cached || []); const [invoices, setInvoices] = useState(() => cached?.invoices || []);
const [stats, setStats] = useState(() => cached?.stats || { completedNewTasks: 0, completedRevisions: 0, invoicedUnits: 0, paidUnits: 0 });
const [loading, setLoading] = useState(() => !cached); const [loading, setLoading] = useState(() => !cached);
const [error, setError] = useState(''); const [error, setError] = useState('');
const { sortKey, sortDir, toggle, sort } = useSortable('created_at'); const [showInvoiceForm, setShowInvoiceForm] = useState(false);
const { sortKey, sortDir, toggle, sort } = useSortable('created_at', 'desc');
const { sortKey: detailSortKey, sortDir: detailSortDir, toggle: toggleDetailSort, sort: sortDetailItems } = useSortable('description');
const [viewingInvoice, setViewingInvoice] = useState(null);
const [detailLoading, setDetailLoading] = useState(false);
const [detailError, setDetailError] = useState('');
const [detailTaskTypeMap, setDetailTaskTypeMap] = useState({});
const [submittingInvoice, setSubmittingInvoice] = useState(false);
const [downloadingInvoice, setDownloadingInvoice] = useState(false);
const [downloadingReceipt, setDownloadingReceipt] = useState(false);
useEffect(() => { useEffect(() => {
if (!currentUser?.id) { setLoading(false); return; } async function load() {
if (!currentUser?.id) {
setInvoices([]);
setStats({ completedNewTasks: 0, completedRevisions: 0, invoicedUnits: 0, paidUnits: 0 });
setLoading(false);
return;
}
setLoading(true);
setError('');
try {
const [{ data: invoiceRows, error: invoiceError }, { data: taskRows, error: taskError }] = await Promise.all([
supabase supabase
.from('subcontractor_invoices') .from('subcontractor_invoices')
.select('*, items:subcontractor_invoice_items(*)') .select('*, items:subcontractor_invoice_items(*)')
.order('created_at', { ascending: false }) .order('created_at', { ascending: false }),
.then(({ data, error: err }) => { supabase
if (err) setError(err.message); .from('tasks')
else { setInvoices(data || []); writePageCache(cacheKey, data || []); } .select('id, title, status, current_version, assigned_to')
setLoading(false); .in('status', ['not_started', 'in_progress', 'on_hold', 'client_review', 'client_approved', 'invoiced', 'paid'])
}); .eq('assigned_to', currentUser.id)
}, [currentUser?.id]); // eslint-disable-line react-hooks/exhaustive-deps .order('title'),
]);
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; if (invoiceError) throw invoiceError;
const chartYear = new Date().getFullYear(); if (taskError) throw taskError;
const chartData = useMemo(() => MONTHS.map((month, mi) => {
const submittedAmt = invoices.filter(i => i.status === 'submitted' && new Date(i.created_at).getFullYear() === chartYear && new Date(i.created_at).getMonth() === mi).reduce((s, i) => s + invoiceTotal(i.items), 0); const tasks = taskRows || [];
const paidAmt = invoices.filter(i => i.status === 'paid' && new Date(i.created_at).getFullYear() === chartYear && new Date(i.created_at).getMonth() === mi).reduce((s, i) => s + invoiceTotal(i.items), 0); const invoicesData = invoiceRows || [];
return { month, Submitted: +submittedAmt.toFixed(2), Paid: +paidAmt.toFixed(2) };
}), [invoices, chartYear]); const taskIds = tasks.map((task) => task.id).filter(Boolean);
const hasChartData = chartData.some(d => d.Submitted > 0 || d.Paid > 0); const { data: submissionRows, error: submissionsError } = taskIds.length > 0
const totalPaid = invoices.filter(i => i.status === 'paid').reduce((s, i) => s + invoiceTotal(i.items), 0); ? await supabase
const totalPending = invoices.filter(i => i.status === 'submitted').reduce((s, i) => s + invoiceTotal(i.items), 0); .from('submissions')
.select('task_id, deliveries(version_number, sent_by, sent_at)')
.in('task_id', taskIds)
: { data: [], error: null };
if (submissionsError) throw submissionsError;
const deliveriesByTask = new Map();
for (const row of (submissionRows || [])) {
const taskId = row.task_id;
if (!taskId) continue;
const bucket = deliveriesByTask.get(taskId) || new Map();
for (const delivery of asArray(row.deliveries)) {
if ((delivery.sent_by || '').trim().toLowerCase() !== (currentUser.name || '').trim().toLowerCase()) continue;
const version = Number(delivery.version_number || 0);
if (!bucket.has(version)) bucket.set(version, delivery);
}
deliveriesByTask.set(taskId, bucket);
}
let completedNewTasks = 0;
let completedRevisions = 0;
for (const task of tasks) {
const versions = [...(deliveriesByTask.get(task.id)?.keys() || [])];
for (const version of versions) {
if (!isCompletedVersionEligible(task, version)) continue;
if (version <= 0) completedNewTasks += 1;
else completedRevisions += 1;
}
}
const invoiceUnits = invoicesData.reduce((sum, invoice) => sum + asArray(invoice.items).filter((item) => item.task_id).length, 0);
const paidUnits = invoicesData
.filter((invoice) => invoice.status === 'paid')
.reduce((sum, invoice) => sum + asArray(invoice.items).filter((item) => item.task_id).length, 0);
const nextStats = {
completedNewTasks,
completedRevisions,
invoicedUnits: invoiceUnits,
paidUnits,
};
setInvoices(invoicesData);
setStats(nextStats);
writePageCache(cacheKey, { invoices: invoicesData, stats: nextStats });
} catch (err) {
console.error('Failed to load subcontractor invoices:', err);
setError(err?.message || 'Failed to load invoices.');
} finally {
setLoading(false);
}
}
load();
}, [cacheKey, currentUser?.id, currentUser?.name]);
const totalInvoiceAmount = useMemo(
() => invoices.reduce((sum, invoice) => sum + invoiceTotal(invoice.items), 0),
[invoices]
);
const paidInvoiceAmount = useMemo(
() => invoices.filter((invoice) => invoice.status === 'paid').reduce((sum, invoice) => sum + invoiceTotal(invoice.items), 0),
[invoices]
);
const sortedInvoices = sort(invoices, (inv, key) => {
if (key === 'total') return invoiceTotal(inv.items);
if (key === 'line_count') return asArray(inv.items).length;
if (key === 'submitted_at') return inv.submitted_at ? new Date(inv.submitted_at).getTime() : 0;
if (key === 'created_at') return inv.created_at ? new Date(inv.created_at).getTime() : 0;
return inv[key] || '';
});
async function openInvoicePopup(invoiceId) {
if (!invoiceId) return;
setDetailLoading(true);
setDetailError('');
try {
const { data, error: err } = await supabase
.from('subcontractor_invoices')
.select('*, items:subcontractor_invoice_items(*)')
.eq('id', invoiceId)
.single();
if (err || !data) throw err || new Error('Invoice not found.');
const taskTypeMap = await fetchTaskTypeMap(asArray(data.items).map((item) => item.task_id));
setDetailTaskTypeMap(taskTypeMap);
setViewingInvoice(data);
} catch (err) {
setDetailError(err?.message || 'Failed to load invoice.');
} finally {
setDetailLoading(false);
}
}
async function handleSubmitInvoice() {
if (!viewingInvoice?.id) return;
setSubmittingInvoice(true);
setDetailError('');
try {
const submittedAt = new Date().toISOString();
const { error: err } = await supabase
.from('subcontractor_invoices')
.update({ status: 'submitted', submitted_at: submittedAt })
.eq('id', viewingInvoice.id);
if (err) throw err;
setViewingInvoice((current) => current ? { ...current, status: 'submitted', submitted_at: submittedAt } : current);
setInvoices((current) => current.map((invoice) => invoice.id === viewingInvoice.id ? { ...invoice, status: 'submitted', submitted_at: submittedAt } : invoice));
} catch (err) {
setDetailError(err?.message || 'Failed to submit invoice.');
} finally {
setSubmittingInvoice(false);
}
}
async function handleDownloadReceipt() {
if (!viewingInvoice) return;
setDownloadingReceipt(true);
setDetailError('');
try {
const total = invoiceTotal(viewingInvoice.items);
await generateSubcontractorPOPDF(
buildPDFArgs(viewingInvoice, { name: currentUser?.name, email: currentUser?.email }, total, 'paid')
);
} catch (err) {
setDetailError(err?.message || 'Failed to download receipt.');
} finally {
setDownloadingReceipt(false);
}
}
async function handleDownloadInvoice() {
if (!viewingInvoice) return;
setDownloadingInvoice(true);
setDetailError('');
try {
const total = invoiceTotal(viewingInvoice.items);
await generateSubcontractorPOPDF(
buildPDFArgs(viewingInvoice, { name: currentUser?.name, email: currentUser?.email }, total)
);
} catch (err) {
setDetailError(err?.message || 'Failed to download invoice.');
} finally {
setDownloadingInvoice(false);
}
}
return ( return (
<Layout> <Layout loading={loading}>
<div className="page-header"> <div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div>
<div className="page-title">Invoices</div>
<div className="page-subtitle">Submit invoices to Fourge Branding for your completed work.</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 4 }}>Payment terms: NET 30 from the date Fourge receives payment from the client.</div>
</div>
<button className="btn btn-primary" onClick={() => navigate('/my-invoices-sub/new')} disabled={loading}>
+ New Invoice
</button>
</div>
{error && <div className="notification notification-info" style={{ marginBottom: 16 }}>{error}</div>} {error && <div className="notification notification-info" style={{ marginBottom: 16 }}>{error}</div>}
{!loading && invoices.length > 0 && ( <div className="dash-stat-grid" style={{ gridTemplateColumns: '1fr 1fr 1fr 1fr', flexShrink: 0 }}>
<> <TaskStatCard
<div className="stat-bar" style={{ marginBottom: 18 }}> label="Completed New Tasks"
<div className="stat-bar-item"><div className="stat-bar-header"><div className="stat-bar-label">Total Paid</div><div className="stat-bar-dot" style={{ background: '#4ade80' }} /></div><div className="stat-bar-value">{fmt(totalPaid)}</div></div> value={stats.completedNewTasks}
<div className="stat-bar-item"><div className="stat-bar-header"><div className="stat-bar-label">Pending Payment</div><div className="stat-bar-dot" style={{ background: '#F5A523' }} /></div><div className="stat-bar-value">{fmt(totalPending)}</div></div> sub={`${stats.completedNewTasks} completed R00 units`}
<div className="stat-bar-item"><div className="stat-bar-header"><div className="stat-bar-label">Total Invoices</div><div className="stat-bar-dot" style={{ background: '#60a5fa' }} /></div><div className="stat-bar-value">{invoices.length}</div></div> iconBg="rgba(96,165,250,0.15)"
iconColor="#60a5fa"
iconPath={STAT_ICONS.newTasks}
/>
<TaskStatCard
label="Completed Revisions"
value={stats.completedRevisions}
sub={`${stats.completedRevisions} completed revision units`}
iconBg="rgba(245,165,35,0.15)"
iconColor="#F5A523"
iconPath={STAT_ICONS.revisions}
/>
<TaskStatCard
label="Invoiced"
value={fmt(totalInvoiceAmount)}
sub={`${invoices.length} invoices submitted`}
iconBg="rgba(167,139,250,0.15)"
iconColor="#a78bfa"
iconPath={STAT_ICONS.invoiced}
/>
<TaskStatCard
label="Paid"
value={fmt(paidInvoiceAmount)}
sub={`${invoices.filter((invoice) => invoice.status === 'paid').length} paid invoices`}
iconBg="rgba(74,222,128,0.15)"
iconColor="#4ade80"
iconPath={STAT_ICONS.paid}
/>
</div> </div>
{hasChartData && (
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '16px 16px 8px', marginBottom: 18 }}>
<ResponsiveContainer width="100%" height={180}>
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="gradSubAmt" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#F5A523" stopOpacity={0.25}/><stop offset="95%" stopColor="#F5A523" stopOpacity={0}/></linearGradient>
<linearGradient id="gradSubPaid" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#4ade80" stopOpacity={0.25}/><stop offset="95%" stopColor="#4ade80" stopOpacity={0}/></linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} />
<XAxis dataKey="month" tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} tickFormatter={v => `$${v >= 1000 ? (v/1000).toFixed(0)+'k' : v}`} width={45} />
<Tooltip formatter={(v) => [`$${Number(v).toLocaleString('en-US', { minimumFractionDigits: 2 })}`, undefined]} contentStyle={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, fontSize: 12 }} />
<Legend wrapperStyle={{ fontSize: 11, paddingTop: 8 }} />
<Area type="monotone" dataKey="Submitted" stroke="#F5A523" strokeWidth={2} fill="url(#gradSubAmt)" dot={false} activeDot={{ r: 4 }} />
<Area type="monotone" dataKey="Paid" stroke="#4ade80" strokeWidth={2} fill="url(#gradSubPaid)" dot={false} activeDot={{ r: 4 }} />
</AreaChart>
</ResponsiveContainer>
</div>
)}
</>
)}
{loading ? ( {invoices.length === 0 ? (
<div className="empty-state">Loading invoices...</div> <>
) : invoices.length === 0 ? ( <div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10, flexShrink: 0, minHeight: 'var(--btn-height)' }}>
<div className="empty-state"> <div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
<h3>No invoices yet</h3> <button className="btn btn-outline" onClick={() => setShowInvoiceForm(true)} disabled={loading}>
<p>Create your first invoice to get paid for your completed work.</p> + Invoice
</button>
</div> </div>
</div>
<div className="card card-empty-center" style={{ flex: 1, minHeight: 0 }}>No invoices</div>
</>
) : ( ) : (
<div className="card"> <>
<div className="table-wrapper"> <div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10, flexShrink: 0, minHeight: 'var(--btn-height)' }}>
<table> <div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
<button className="btn btn-outline" onClick={() => setShowInvoiceForm(true)} disabled={loading}>
+ Invoice
</button>
</div>
</div>
<div
style={{
background: 'var(--card-bg)',
border: '1px solid var(--border)',
borderRadius: 8,
padding: '18px 21px',
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
display: 'flex',
flexDirection: 'column',
flex: 1,
minHeight: 0,
}}
>
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table className="table-sticky-head" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
<colgroup>
<col style={{ width: '28%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '12%' }} />
<col style={{ width: '15%' }} />
</colgroup>
<thead> <thead>
<tr> <tr>
<SortTh col="invoice_number" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Invoice #</SortTh> <SortTh col="invoice_number" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Invoice #</SortTh>
<SortTh col="created_at" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Created</SortTh>
<SortTh col="submitted_at" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Submitted</SortTh> <SortTh col="submitted_at" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Submitted</SortTh>
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Status</SortTh> <SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Status</SortTh>
<SortTh col="line_count" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'center' }}>Items</SortTh>
<SortTh col="total" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Total</SortTh> <SortTh col="total" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Total</SortTh>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{sort(invoices, (inv, key) => { {sortedInvoices.map((inv) => {
if (key === 'total') return invoiceTotal(inv.items);
if (key === 'submitted_at') return inv.submitted_at ? new Date(inv.submitted_at).getTime() : 0;
return inv[key] || '';
}).map(inv => {
const total = invoiceTotal(inv.items); const total = invoiceTotal(inv.items);
return ( return (
<tr key={inv.id} style={{ cursor: 'pointer' }} onClick={() => navigate(`/my-invoices-sub/${inv.id}`)}> <tr key={inv.id} style={{ cursor: 'pointer' }} onClick={() => openInvoicePopup(inv.id)}>
<td style={{ fontWeight: 400 }}>{inv.invoice_number}</td> <td style={{ fontWeight: 400 }}>{inv.invoice_number || 'Subcontractor Invoice'}</td>
<td style={{ color: 'var(--text-muted)' }}>{inv.created_at ? new Date(inv.created_at).toLocaleDateString() : '—'}</td>
<td style={{ color: 'var(--text-muted)' }}>{inv.submitted_at ? new Date(inv.submitted_at).toLocaleDateString() : '—'}</td> <td style={{ color: 'var(--text-muted)' }}>{inv.submitted_at ? new Date(inv.submitted_at).toLocaleDateString() : '—'}</td>
<td><StatusBadge status={STATUS_BADGE[inv.status]} label={STATUS_LABEL[inv.status]} /></td> <td><StatusBadge status={STATUS_BADGE[inv.status]} label={STATUS_LABEL[inv.status]} /></td>
<td style={{ textAlign: 'center', color: 'var(--text-primary)' }}>{asArray(inv.items).length}</td>
<td style={{ textAlign: 'right', fontWeight: 400, color: 'var(--accent)' }}>{fmt(total)}</td> <td style={{ textAlign: 'right', fontWeight: 400, color: 'var(--accent)' }}>{fmt(total)}</td>
</tr> </tr>
); );
@@ -137,6 +421,147 @@ export default function MyInvoices() {
</table> </table>
</div> </div>
</div> </div>
</>
)}
</div>
{showInvoiceForm && (
<div style={popupOverlayStyle} onClick={() => setShowInvoiceForm(false)}>
<div
style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }}
onClick={(e) => e.stopPropagation()}
>
<SubcontractorInvoiceForm
embedded
onCancel={() => setShowInvoiceForm(false)}
onSubmitted={(inv) => {
setShowInvoiceForm(false);
openInvoicePopup(inv.id);
}}
/>
</div>
</div>
)}
{(viewingInvoice || detailLoading || detailError) && (
<div style={popupOverlayStyle} onClick={() => { if (!submittingInvoice && !downloadingInvoice && !downloadingReceipt) { setViewingInvoice(null); setDetailError(''); } }}>
<div
style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }}
onClick={(e) => e.stopPropagation()}
>
{detailLoading && !viewingInvoice ? (
<div className="card-empty-center" style={{ flex: 1, minHeight: 0 }}>Loading invoice</div>
) : viewingInvoice ? (() => {
const total = invoiceTotal(viewingInvoice.items);
const sortedItems = [...(viewingInvoice.items || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
const invoiceStatus = viewingInvoice.status ? viewingInvoice.status.charAt(0).toUpperCase() + viewingInvoice.status.slice(1) : '—';
return (
<>
{detailError ? <div className="notification notification-info" style={{ marginBottom: 16, flexShrink: 0 }}>{detailError}</div> : null}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16, paddingBottom: 18, borderBottom: '1px solid var(--border)' }}>
<div>
<div style={{ fontSize: 18, fontWeight: 500, color: 'var(--text-primary)', letterSpacing: 0.2, lineHeight: 1.1 }}>{viewingInvoice.invoice_number || 'Subcontractor Invoice'}</div>
<div style={{ marginTop: 6, fontSize: 13, color: 'var(--text-secondary)' }}>
{currentUser?.name || 'Subcontractor'}{currentUser?.email ? ` · ${currentUser.email}` : ''}
</div>
</div>
<StatusBadge status={STATUS_BADGE[viewingInvoice.status] || 'not_started'} label={invoiceStatus} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 18, padding: '18px 0', borderBottom: '1px solid var(--border)' }}>
<div>
<div style={FIELD_LABEL_STYLE}>Created</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{viewingInvoice.created_at ? new Date(viewingInvoice.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div>
</div>
<div>
<div style={FIELD_LABEL_STYLE}>Submitted</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{viewingInvoice.submitted_at ? new Date(viewingInvoice.submitted_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div>
</div>
<div>
<div style={FIELD_LABEL_STYLE}>Line Items</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{sortedItems.length}</div>
</div>
<div>
<div style={FIELD_LABEL_STYLE}>Total</div>
<div style={{ fontSize: 18, fontWeight: 500, color: 'var(--accent)', lineHeight: 1.1 }}>${total.toFixed(2)}</div>
</div>
</div>
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto', paddingTop: 18 }}>
{sortedItems.length === 0 ? (
<div className="card-empty-center">No line items</div>
) : (
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '8%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '32%' }} />
<col style={{ width: '16%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '12%' }} />
<col style={{ width: '12%' }} />
</colgroup>
<thead>
<tr>
<th style={{ textAlign: 'center' }}>Work</th>
<th style={{ textAlign: 'center' }}>R#</th>
<th style={{ textAlign: 'left' }}>Description</th>
<th style={{ textAlign: 'center' }}>Type</th>
<th style={{ textAlign: 'center' }}>Qty</th>
<th style={{ textAlign: 'center' }}>Unit Price</th>
<th style={{ textAlign: 'center' }}>Amount</th>
</tr>
</thead>
<tbody>
{sortedItems.map(item => (
<tr key={item.id}>
<td style={{ padding: '5px 0', fontSize: 13, color: 'var(--text-primary)', textAlign: 'center' }}>{itemWorkLabel(item)}</td>
<td style={{ padding: '5px 0', fontSize: 13, color: 'var(--text-primary)', textAlign: 'center' }}>{itemVersionLabel(item)}</td>
<td style={{ padding: '5px 0', fontSize: 13, color: 'var(--text-primary)' }}>{cleanItemDescription(item)}</td>
<td style={{ padding: '5px 0', fontSize: 13, color: 'var(--text-primary)', textAlign: 'center' }}>{detailTaskTypeMap[item.task_id] || 'Other'}</td>
<td style={{ padding: '5px 0', textAlign: 'center', fontSize: 13, color: 'var(--text-primary)' }}>{item.quantity || 1}</td>
<td style={{ padding: '5px 0', textAlign: 'center', fontSize: 13, color: 'var(--text-primary)' }}>${Number(item.unit_price || 0).toFixed(2)}</td>
<td style={{ padding: '5px 0', textAlign: 'center', fontSize: 13, color: 'var(--text-primary)' }}>${(Number(item.unit_price || 0) * Number(item.quantity || 1)).toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
)}
{viewingInvoice.notes && (
<div style={{ marginTop: 18, padding: '12px 14px', background: 'var(--card-bg-2)', borderRadius: 6, border: '1px solid var(--border)' }}>
<div style={FIELD_LABEL_STYLE}>Notes</div>
<div style={{ fontSize: 13, color: 'var(--text-secondary)', whiteSpace: 'pre-wrap' }}>{viewingInvoice.notes}</div>
</div>
)}
</div>
<div className="modal-action-row" style={{ paddingTop: 18, borderTop: '1px solid var(--border)' }}>
<LoadingButton className="btn btn-outline" loading={downloadingInvoice} loadingText="Generating…" disabled={downloadingInvoice || submittingInvoice || downloadingReceipt} onClick={handleDownloadInvoice}>
Download Invoice
</LoadingButton>
{viewingInvoice.status === 'draft' && (
<LoadingButton className="btn btn-outline" loading={submittingInvoice} loadingText="Submitting…" disabled={submittingInvoice || downloadingInvoice || downloadingReceipt} onClick={handleSubmitInvoice}>
Submit to Team
</LoadingButton>
)}
{viewingInvoice.status === 'paid' && (
<LoadingButton className="btn btn-outline" loading={downloadingReceipt} loadingText="Generating…" disabled={downloadingReceipt || downloadingInvoice || submittingInvoice} onClick={handleDownloadReceipt}>
Download Receipt
</LoadingButton>
)}
<button type="button" className="btn btn-outline" onClick={() => { setViewingInvoice(null); setDetailError(''); }} disabled={submittingInvoice || downloadingInvoice || downloadingReceipt}>
Cancel
</button>
</div>
</>
);
})() : (
<div className="card-empty-center" style={{ flex: 1, minHeight: 0 }}>{detailError || 'Invoice not found.'}</div>
)}
</div>
</div>
)} )}
</Layout> </Layout>
); );
+3 -5
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import Layout from '../../components/Layout'; import Layout from '../../components/Layout';
import PageLoader from '../../components/PageLoader';
import StatusBadge from '../../components/StatusBadge'; import StatusBadge from '../../components/StatusBadge';
import { supabase } from '../../lib/supabase'; import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/AuthContext';
@@ -95,14 +96,11 @@ export default function MyPurchaseOrders() {
</div> </div>
{loading ? ( {loading ? (
<p style={{ color: 'var(--text-muted)' }}>Loading...</p> <PageLoader />
) : error ? ( ) : error ? (
<div className="card" style={{ color: 'var(--danger)' }}>{error}</div> <div className="card" style={{ color: 'var(--danger)' }}>{error}</div>
) : purchaseOrders.length === 0 ? ( ) : purchaseOrders.length === 0 ? (
<div className="empty-state"> <div className="card card-empty-center">No purchase orders</div>
<h3>No purchase orders</h3>
<p>New POs will appear here when the Fourge team sends them.</p>
</div>
) : ( ) : (
<div style={{ display: 'grid', gap: 12 }}> <div style={{ display: 'grid', gap: 12 }}>
{purchaseOrders.map(po => ( {purchaseOrders.map(po => (
+22 -23
View File
@@ -7,6 +7,8 @@ import { useAuth } from '../../context/AuthContext';
import { generateInvoicePDF } from '../../lib/invoice'; import { generateInvoicePDF } from '../../lib/invoice';
import { blobToEmailAttachment, sendEmail } from '../../lib/email'; import { blobToEmailAttachment, sendEmail } from '../../lib/email';
import { withTimeout } from '../../lib/withTimeout'; import { withTimeout } from '../../lib/withTimeout';
import { isReviewShadowDescription } from '../../lib/taskVersions';
import { getRevisionChargeQuantity, isCompletedVersionEligible, isInitialVersionEligible } from '../../lib/invoiceVersionRules';
// Computed at module load time stable for the lifetime of the invoice creation session // Computed at module load time stable for the lifetime of the invoice creation session
const INVOICE_TODAY = new Date().toISOString().split('T')[0]; const INVOICE_TODAY = new Date().toISOString().split('T')[0];
@@ -29,12 +31,6 @@ const buildRevisionItemDescription = (revision) => {
return `${projectName}${taskTitle} • Revision ${versionLabel}`; return `${projectName}${taskTitle} • Revision ${versionLabel}`;
}; };
const getRevisionChargeQuantity = (revision) => {
const version = Number(revision?.version_number || 0);
// Incremental billing: R01 is free, each uninvoiced revision from R02+ is one charge unit.
return version >= 2 ? 1 : 0;
};
export default function CreateInvoice() { export default function CreateInvoice() {
const navigate = useNavigate(); const navigate = useNavigate();
const { currentUser } = useAuth(); const { currentUser } = useAuth();
@@ -96,30 +92,30 @@ export default function CreateInvoice() {
setInvoiceEmail(recipients[0]?.email || companies.find(c => c.id === selectedCompanyId)?.contact_email || ''); setInvoiceEmail(recipients[0]?.email || companies.find(c => c.id === selectedCompanyId)?.contact_email || '');
if (projects && projects.length > 0) { if (projects && projects.length > 0) {
const projectIds = projects.map(p => p.id); const projectIds = projects.map(p => p.id);
const { data: tasks } = await supabase const { data: companyTasks } = await supabase
.from('tasks') .from('tasks')
.select('*, project:projects(name), submissions(service_type, type, version_number)') .select('*, project:projects(name), submissions(service_type, type, version_number)')
.in('project_id', projectIds) .in('project_id', projectIds)
.eq('invoiced', false) .in('status', ['not_started', 'in_progress', 'on_hold', 'client_review', 'client_approved', 'invoiced', 'paid']);
.eq('status', 'client_approved'); const companyTaskIds = (companyTasks || []).map((t) => t.id).filter(Boolean);
const approvedTaskIds = (tasks || []).map((t) => t.id).filter(Boolean); const { data: revisions } = companyTaskIds.length > 0
const { data: revisions } = approvedTaskIds.length > 0
? await supabase ? await supabase
.from('submissions') .from('submissions')
.select('*, task:tasks(id, title, project:projects(name), submissions(service_type, type))') .select('*, task:tasks(id, title, status, current_version, project:projects(name), submissions(service_type, type))')
.eq('type', 'revision') .eq('type', 'revision')
.or('revision_type.eq.client_revision,revision_type.is.null')
.eq('invoiced', false) .eq('invoiced', false)
.in('task_id', approvedTaskIds) .in('task_id', companyTaskIds)
.order('submitted_at', { ascending: false })
: { data: [] }; : { data: [] };
const tasksWithService = (tasks || []).map(t => { const tasksWithService = (companyTasks || []).filter(isInitialVersionEligible).map(t => {
const initial = (t.submissions || []).find(s => s.type === 'initial') || (t.submissions || [])[0]; const initial = (t.submissions || []).find(s => s.type === 'initial') || (t.submissions || [])[0];
return { ...t, service_type: initial?.service_type || t.title }; return { ...t, service_type: initial?.service_type || t.title };
}); });
setUninvoicedTasks(tasksWithService); setUninvoicedTasks(tasksWithService);
// Deduplicate by (task_id, version_number) multiple submission rows per version (e.g. "Add Files") must not produce multiple invoice charges // Deduplicate by (task_id, version_number) multiple submission rows per version (e.g. "Add Files") must not produce multiple invoice charges
const revMap = new Map(); const revMap = new Map();
for (const rev of (revisions || [])) { for (const rev of (revisions || []).filter(rev => !isReviewShadowDescription(rev.description))) {
if (!isCompletedVersionEligible(rev.task, rev.version_number)) continue;
const key = `${rev.task_id}:${rev.version_number}`; const key = `${rev.task_id}:${rev.version_number}`;
if (!revMap.has(key)) revMap.set(key, rev); if (!revMap.has(key)) revMap.set(key, rev);
} }
@@ -152,7 +148,7 @@ export default function CreateInvoice() {
const serviceLabel = getRevisionServiceType(revision); const serviceLabel = getRevisionServiceType(revision);
const description = buildRevisionItemDescription(revision); const description = buildRevisionItemDescription(revision);
const price = priceList.find(p => p.service_type === serviceLabel && p.price_type === 'revision'); const price = priceList.find(p => p.service_type === serviceLabel && p.price_type === 'revision');
const revisionChargeQty = getRevisionChargeQuantity(revision); const revisionChargeQty = getRevisionChargeQuantity(revision?.version_number);
const quantity = revisionChargeQty > 0 ? revisionChargeQty : 1; const quantity = revisionChargeQty > 0 ? revisionChargeQty : 1;
const unitPrice = revisionChargeQty > 0 ? (price?.price || '') : 0; const unitPrice = revisionChargeQty > 0 ? (price?.price || '') : 0;
setItems(prev => { setItems(prev => {
@@ -324,7 +320,7 @@ export default function CreateInvoice() {
return ( return (
<Layout> <Layout>
<button className="back-link" onClick={() => navigate('/invoices')}> Back to Invoices</button> <button className="back-link" onClick={() => navigate('/finances')}> Back to Invoices</button>
<div className="page-header"> <div className="page-header">
<div> <div>
@@ -403,8 +399,9 @@ export default function CreateInvoice() {
<div key={task.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}> <div key={task.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<div> <div>
<div style={{ fontSize: 13, fontWeight: 400 }}>{buildNewItemDescription(task)}</div> <div style={{ fontSize: 13, fontWeight: 400 }}>{buildNewItemDescription(task)}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', fontSize: 11, color: 'var(--text-muted)' }}>
{task.service_type || 'Other'} {price ? `$${Number(price.price).toFixed(2)}` : 'No price set'} <span className="badge badge-initial">New</span>
<span>{task.service_type || 'Other'} {price ? `$${Number(price.price).toFixed(2)}` : 'No price set'}</span>
</div> </div>
</div> </div>
<button <button
@@ -441,13 +438,15 @@ export default function CreateInvoice() {
{uninvoicedRevisions.map(rev => { {uninvoicedRevisions.map(rev => {
const revServiceType = getRevisionServiceType(rev); const revServiceType = getRevisionServiceType(rev);
const price = priceList.find(p => p.service_type === revServiceType && p.price_type === 'revision'); const price = priceList.find(p => p.service_type === revServiceType && p.price_type === 'revision');
const revisionChargeQty = getRevisionChargeQuantity(rev?.version_number);
const alreadyAdded = items.some(i => i.submission_id === rev.id); const alreadyAdded = items.some(i => i.submission_id === rev.id);
return ( return (
<div key={rev.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}> <div key={rev.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<div> <div>
<div style={{ fontSize: 13, fontWeight: 400 }}>{buildRevisionItemDescription(rev)}</div> <div style={{ fontSize: 13, fontWeight: 400 }}>{buildRevisionItemDescription(rev)}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', fontSize: 11, color: 'var(--text-muted)' }}>
{revServiceType || 'Other'} {price ? `$${Number(price.price).toFixed(2)}` : 'No price set'} <span className="badge badge-client_revision">Revision</span>
<span>{revServiceType || 'Other'} {revisionChargeQty > 0 ? (price ? `$${Number(price.price).toFixed(2)}` : 'No price set') : 'Free'}</span>
</div> </div>
</div> </div>
<button <button
@@ -537,7 +536,7 @@ export default function CreateInvoice() {
<LoadingButton className="btn btn-primary" onClick={() => handleSave('sent')} loading={saving} loadingText="Finalizing & Sending..."> <LoadingButton className="btn btn-primary" onClick={() => handleSave('sent')} loading={saving} loadingText="Finalizing & Sending...">
Finalise & Send Finalise & Send
</LoadingButton> </LoadingButton>
<button className="btn btn-outline" onClick={() => navigate('/invoices')}>Cancel</button> <button className="btn btn-outline" onClick={() => navigate('/finances')}>Cancel</button>
</div> </div>
</Layout> </Layout>
); );
+3 -27
View File
@@ -3,7 +3,6 @@ import { useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout'; import Layout from '../../components/Layout';
import { supabase } from '../../lib/supabase'; import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/AuthContext';
import { sendEmail } from '../../lib/email';
const FIELD_LABEL_STYLE = { fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', marginBottom: 4 }; const FIELD_LABEL_STYLE = { fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', marginBottom: 4 };
const FIELD_INPUT_STYLE = { minHeight: 42, margin: 0 }; const FIELD_INPUT_STYLE = { minHeight: 42, margin: 0 };
@@ -180,35 +179,12 @@ export default function CreateSubcontractorPO() {
return; return;
} }
if (status === 'sent') { navigate('/finances', { state: { tab: 'subcontractor-po' } });
const subcontractor = externalProfiles.find(profile => profile.id === form.profile_id);
const project = projects.find(row => row.id === form.project_id);
if (subcontractor?.email) {
try {
await sendEmail('subcontractor_po_sent', subcontractor.email, {
poNumber: data.po_number || 'Purchase Order',
subcontractorName: subcontractor.name || 'there',
projectName: project?.name || 'Subcontractor Work',
companyName: project?.company?.name || 'Fourge Branding',
amount: `$${total.toFixed(2)}`,
dueDate: form.due_date ? new Date(form.due_date).toLocaleDateString() : 'Not set',
terms: form.terms.trim() || 'Net 15',
scope: lineItems.map(item => `${item.description} - $${item.amount.toFixed(2)}`).join('\n'),
portalUrl: 'https://portal.fourgebranding.com/my-purchase-orders',
});
} catch (emailError) {
alert(`PO was finalized, but the email failed: ${emailError.message || 'unknown error'}`);
}
} else {
alert('PO was finalized, but this subcontractor has no email on file.');
}
}
navigate('/invoices', { state: { tab: 'subcontractor-po' } });
}; };
return ( return (
<Layout> <Layout loading={loading}>
<button className="back-link" onClick={() => navigate('/invoices', { state: { tab: 'subcontractor-po' } })}> Back to Subcontractor PO</button> <button className="back-link" onClick={() => navigate('/finances', { state: { tab: 'subcontractor-po' } })}> Back to Subcontractor PO</button>
<div className="page-header"> <div className="page-header">
<div> <div>
+174 -195
View File
@@ -1,15 +1,18 @@
import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; import { useState, useEffect, useMemo, useRef } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout'; import Layout from '../../components/Layout';
import PageLoader from '../../components/PageLoader';
import SortTh from '../../components/SortTh'; import SortTh from '../../components/SortTh';
import ProfileAvatar from '../../components/ProfileAvatar';
import { supabase } from '../../lib/supabase'; import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/AuthContext';
import { readPageCache, writePageCache } from '../../lib/pageCache'; import { readPageCache, writePageCache } from '../../lib/pageCache';
import { withTimeout } from '../../lib/withTimeout'; import { withTimeout } from '../../lib/withTimeout';
import { getDeadlineSourceSubmission } from '../../lib/taskDeadlines'; import { getDeadlineSourceSubmission } from '../../lib/taskDeadlines';
import { useSortable } from '../../hooks/useSortable'; import { useSortable } from '../../hooks/useSortable';
import { useRefetchOnFocus } from '../../hooks/useRefetchOnFocus'; import { useLiveRefresh } from '../../hooks/useLiveRefresh';
import { useRealtimeSubscription } from '../../hooks/useRealtimeSubscription'; import { resolveScopedWorkIds } from '../../lib/workScope';
import { getSubmissionDisplayName, mergeSubmissionDisplayNames } from '../../lib/submissionDisplay';
// Helpers // Helpers
@@ -32,22 +35,6 @@ function fmtMoney(n) {
return `$${Number(n).toFixed(2)}`; return `$${Number(n).toFixed(2)}`;
} }
function buildClientHighlights(companies, projects, tasks, clientProfiles, companyMemberships = [], invoices = []) {
const doneStatuses = ['client_approved', 'invoiced', 'paid'];
return (companies || []).map(company => {
const companyProjects = (projects || []).filter(p => p.company_id === company.id);
const primaryContact = (clientProfiles || []).find(p =>
p.company_id === company.id ||
(companyMemberships || []).some(m => m.company_id === company.id && m.profile_id === p.id)
);
const openTasks = (tasks || []).filter(t => companyProjects.some(p => p.id === t.project_id) && !doneStatuses.includes(t.status));
const companyInvoices = (invoices || []).filter(i => i.company_id === company.id);
const outstandingTotal = companyInvoices.filter(i => i.status === 'sent').reduce((s, i) => s + Number(i.total || 0), 0);
const paidTotal = companyInvoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total || 0), 0);
return { company, primaryContact, projectCount: companyProjects.length, openTaskCount: openTasks.length, outstandingTotal, paidTotal };
});
}
const ACTION_ICON = { const ACTION_ICON = {
task_started: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: <polygon points="6,4 20,12 6,20" fill="currentColor"/> }, task_started: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: <polygon points="6,4 20,12 6,20" fill="currentColor"/> },
task_resumed: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: <polygon points="6,4 20,12 6,20" fill="currentColor"/> }, task_resumed: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: <polygon points="6,4 20,12 6,20" fill="currentColor"/> },
@@ -75,25 +62,6 @@ function ActionIcon({ actionKey, size = 27 }) {
); );
} }
function Avatar({ name, size = 27 }) {
return (
<div style={{ width: size, height: size, borderRadius: '50%', background: 'rgba(245,165,35,0.15)', flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="#F5A523">
<circle cx="12" cy="8" r="4" />
<path d="M4 20c0-4.4 3.6-7 8-7s8 2.6 8 7" />
</svg>
</div>
);
}
function InitialPortrait({ name }) {
return (
<div style={{ width: 27, height: 27, borderRadius: '50%', background: 'var(--accent)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: '#000', lineHeight: 1 }}>{(name || '?')[0].toUpperCase()}</span>
</div>
);
}
function smoothCurve(pts) { function smoothCurve(pts) {
if (pts.length < 2) return ''; if (pts.length < 2) return '';
let d = `M${pts[0][0].toFixed(1)},${pts[0][1].toFixed(1)}`; let d = `M${pts[0][0].toFixed(1)},${pts[0][1].toFixed(1)}`;
@@ -373,16 +341,26 @@ function TasksInProgressCard({ tasks = [] }) {
); );
} }
function HotItemsCard({ submissions, tasks, isClient = false }) { function HotItemsCard({ submissions, tasks, isClient = false, isExternal = false, currentUserId = null, cardHeight = null }) {
const navigate = useNavigate(); const navigate = useNavigate();
const { sortKey, sortDir, toggle, sort } = useSortable('deadline'); const { sortKey, sortDir, toggle, sort } = useSortable('deadline');
const taskMap = new Map((tasks || []).map(t => [t.id, t])); const taskMap = new Map((tasks || []).map(t => [t.id, t]));
const seen = new Set(); const seen = new Set();
const hotItems = (submissions || []) const activeStatuses = ['not_started', 'in_progress', 'client_review', 'on_hold'];
const hotItems = isExternal
? (tasks || [])
.filter(task => task?.assigned_to === currentUserId && activeStatuses.includes(task?.status))
.sort((a, b) => {
if (!a.deadline && !b.deadline) return 0;
if (!a.deadline) return 1;
if (!b.deadline) return -1;
return new Date(a.deadline) - new Date(b.deadline);
})
.slice(0, 8)
: (submissions || [])
.filter(s => { .filter(s => {
const task = taskMap.get(s.task_id); const task = taskMap.get(s.task_id);
if (isClient) return task?.status === 'client_review' && !seen.has(s.task_id) && seen.add(s.task_id); if (isClient) return task?.status === 'client_review' && !seen.has(s.task_id) && seen.add(s.task_id);
const activeStatuses = ['not_started', 'in_progress', 'client_review', 'on_hold'];
return s.is_hot && activeStatuses.includes(task?.status) && !seen.has(s.task_id) && seen.add(s.task_id); return s.is_hot && activeStatuses.includes(task?.status) && !seen.has(s.task_id) && seen.add(s.task_id);
}) })
.map(s => ({ ...s, task: taskMap.get(s.task_id) })) .map(s => ({ ...s, task: taskMap.get(s.task_id) }))
@@ -395,24 +373,63 @@ function HotItemsCard({ submissions, tasks, isClient = false }) {
}) })
.slice(0, 7); .slice(0, 7);
const sortedHotItems = sort(hotItems, (s, key) => { const sortedHotItems = sort(hotItems, (s, key) => {
if (isExternal) {
if (key === 'task') return s.title || '';
if (key === 'project') return s.project_name || '';
if (key === 'status') return s.status || '';
if (key === 'deadline') return s.deadline || '';
return '';
}
if (key === 'task') return s.task?.title || ''; if (key === 'task') return s.task?.title || '';
if (key === 'requested_by') return s.submitted_by_name || ''; if (key === 'requested_by') return getSubmissionDisplayName(s) || '';
if (key === 'deadline') return s.deadline || ''; if (key === 'deadline') return s.deadline || '';
return ''; return '';
}); });
return ( return (
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column' }}> <div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column', height: cardHeight || 'auto', minHeight: cardHeight ? 0 : 280 }}>
<div style={{ marginBottom: hotItems.length > 0 ? 14 : 0, flexShrink: 0 }}> <div style={{ marginBottom: hotItems.length > 0 ? 14 : 0, flexShrink: 0 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}> <span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>
{isClient ? 'Tasks Ready For Review' : 'Hot Tasks'} {isClient ? 'Tasks Ready For Review' : isExternal ? 'My Tasks' : 'Hot Tasks'}
</span> </span>
</div> </div>
{hotItems.length === 0 ? ( {hotItems.length === 0 ? (
<div style={{ minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}> <div style={{ flex: 1, minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>
{isClient ? 'No tasks ready for review' : 'No hot tasks'} {isClient ? 'No tasks ready for review' : isExternal ? 'No active tasks assigned to you' : 'No hot tasks'}
</div> </div>
) : ( ) : (
<div className="table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}> <table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
{isExternal ? (
<>
<colgroup>
<col style={{ width: '42%' }} />
<col style={{ width: '24%' }} />
<col style={{ width: '19%' }} />
<col style={{ width: '15%' }} />
</colgroup>
<thead style={{ background: 'transparent' }}>
<tr style={{ background: 'transparent' }}>
<SortTh col="task" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'left', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 5px', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Task</SortTh>
<SortTh col="project" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'left', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Project</SortTh>
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Status</SortTh>
<SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Due By</SortTh>
</tr>
</thead>
<tbody>
{sortedHotItems.map((task) => (
<tr key={task.id} style={{ verticalAlign: 'middle', background: 'transparent' }}>
<td style={{ fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', padding: '5px', border: 'none', background: 'transparent', textAlign: 'left' }}>
<button type="button" className="dashboard-inline-link" onClick={() => navigate('/tasks/' + task.id)}>{task.title}</button>
</td>
<td style={{ fontSize: 12, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', padding: '5px', border: 'none', background: 'transparent', textAlign: 'left' }}>{task.project?.name || '—'}</td>
<td style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap', textAlign: 'center', padding: '5px', border: 'none', background: 'transparent', textTransform: 'capitalize' }}>{String(task.status || '').replace(/_/g, ' ') || '—'}</td>
<td style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap', textAlign: 'center', padding: '5px', border: 'none', background: 'transparent' }}>{task.deadline ? new Date(task.deadline).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '—'}</td>
</tr>
))}
</tbody>
</>
) : (
<>
<colgroup> <colgroup>
<col style={{ width: '10%' }} /> <col style={{ width: '10%' }} />
<col style={{ width: '40%' }} /> <col style={{ width: '40%' }} />
@@ -446,97 +463,37 @@ function HotItemsCard({ submissions, tasks, isClient = false }) {
</td> </td>
<td style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', padding: '5px', border: 'none', background: 'transparent', textAlign: 'center' }}> <td style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', padding: '5px', border: 'none', background: 'transparent', textAlign: 'center' }}>
{s.submitted_by ? ( {s.submitted_by ? (
<button type="button" className="dashboard-inline-link" onClick={() => navigate(profilePath(s.submitted_by, s.submitter_role))}>{s.submitted_by_name || 'Profile'}</button> <button type="button" className="dashboard-inline-link" onClick={() => navigate(profilePath(s.submitted_by, s.submitter_role))}>{getSubmissionDisplayName(s) || 'Profile'}</button>
) : (s.submitted_by_name || '—')} ) : (getSubmissionDisplayName(s) || '—')}
</td> </td>
<td style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap', textAlign: 'center', padding: '5px', border: 'none', background: 'transparent' }}>{s.deadline ? new Date(s.deadline).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '—'}</td> <td style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap', textAlign: 'center', padding: '5px', border: 'none', background: 'transparent' }}>{s.deadline ? new Date(s.deadline).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '—'}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</>
)}
</table> </table>
</div>
)} )}
</div> </div>
); );
} }
function ClientHighlightCard({ highlights }) { function TeamPerformanceCard({ tasks, profiles, deliveries, cardHeight = null }) {
const navigate = useNavigate();
const { sortKey, sortDir, toggle, sort } = useSortable('company');
const fmt = (n) => `$${Number(n || 0).toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`;
const thStyle = { fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' };
const td = () => ({ padding: '5px', border: 'none', background: 'transparent', textAlign: 'center', verticalAlign: 'middle' });
const sortedHighlights = sort(highlights || [], (row, key) => {
if (key === 'company') return row.company?.name || '';
if (key === 'contact') return row.primaryContact?.name || '';
if (key === 'projects') return row.projectCount || 0;
if (key === 'open_tasks') return row.openTaskCount || 0;
if (key === 'outstanding') return Number(row.outstandingTotal || 0);
if (key === 'paid') return Number(row.paidTotal || 0);
return '';
});
return (
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column' }}>
<div style={{ marginBottom: highlights && highlights.length > 0 ? 14 : 0, flexShrink: 0 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Client Highlight</span>
</div>
{!highlights || highlights.length === 0 ? (
<div style={{ minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No data</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '5%' }} />
<col style={{ width: '22%' }} />
<col style={{ width: '23%' }} />
<col style={{ width: '13%' }} />
<col style={{ width: '13%' }} />
<col style={{ width: '12%' }} />
<col style={{ width: '12%' }} />
</colgroup>
<thead style={{ background: 'transparent' }}>
<tr style={{ background: 'transparent' }}>
<th style={{ ...thStyle, padding: '0 0 12px 5px' }} />
<SortTh col="company" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...thStyle, textAlign: 'left', paddingLeft: 5 }}>Company</SortTh>
<SortTh col="contact" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={thStyle}>Contact</SortTh>
<SortTh col="projects" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={thStyle}>Projects</SortTh>
<SortTh col="open_tasks" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={thStyle}>Open Tasks</SortTh>
<SortTh col="outstanding" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={thStyle}>Outstanding</SortTh>
<SortTh col="paid" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={thStyle}>Paid</SortTh>
</tr>
</thead>
<tbody>
{sortedHighlights.map(({ company, primaryContact, projectCount, openTaskCount, outstandingTotal = 0, paidTotal = 0 }) => (
<tr key={company.id} style={{ background: 'transparent' }}>
<td style={{ ...td(), padding: '3px 5px 7px' }}>
<InitialPortrait name={company.name} />
</td>
<td style={{ ...td(), fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'left' }}>
<button type="button" className="dashboard-inline-link" onClick={() => navigate(`/company/${company.id}`)}>{company.name}</button>
</td>
<td style={{ ...td(), fontSize: 12, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{primaryContact?.id ? (
<button type="button" className="dashboard-inline-link" onClick={() => navigate(profilePath(primaryContact.id, primaryContact.role))}>{primaryContact.name || 'Profile'}</button>
) : (primaryContact?.name || '—')}
</td>
<td style={{ ...td(), fontSize: 12, color: 'var(--text-primary)' }}>{projectCount}</td>
<td style={{ ...td(), fontSize: 12, color: 'var(--text-primary)' }}>{openTaskCount || 0}</td>
<td style={{ ...td(), fontSize: 12, color: '#F5A523' }}>{fmt(outstandingTotal)}</td>
<td style={{ ...td(), fontSize: 12, color: '#4ade80' }}>{fmt(paidTotal)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}
function TeamPerformanceCard({ tasks, profiles, deliveries }) {
const navigate = useNavigate(); const navigate = useNavigate();
const profileMap = useMemo(() => { const profileMap = useMemo(() => {
const m = new Map(); const m = new Map();
(profiles || []).forEach(p => m.set(p.id, p)); (profiles || []).forEach(p => m.set(p.id, p));
return m; return m;
}, [profiles]); }, [profiles]);
const profileNameMap = useMemo(() => {
const m = new Map();
(profiles || []).forEach((profile) => {
const key = String(profile?.name || '').trim().toLowerCase();
if (key && !m.has(key)) m.set(key, profile);
});
return m;
}, [profiles]);
const doneStatuses = ['client_approved', 'invoiced', 'paid']; const doneStatuses = ['client_approved', 'invoiced', 'paid'];
const toEST = (dateStr) => new Date(new Date(dateStr).toLocaleString('en-US', { timeZone: 'America/New_York' })); const toEST = (dateStr) => new Date(new Date(dateStr).toLocaleString('en-US', { timeZone: 'America/New_York' }));
const monthsWithData = useMemo(() => new Set( const monthsWithData = useMemo(() => new Set(
@@ -556,9 +513,12 @@ function TeamPerformanceCard({ tasks, profiles, deliveries }) {
}, []); }, []);
const monthOpts = allMonthOpts.filter(o => monthsWithData.has(o.value)); const monthOpts = allMonthOpts.filter(o => monthsWithData.has(o.value));
const [monthKey, setMonthKey] = useState(() => monthOpts[0]?.value || allMonthOpts[0].value); const [monthKey, setMonthKey] = useState(() => monthOpts[0]?.value || allMonthOpts[0].value);
const effectiveMonthKey = monthOpts.some(option => option.value === monthKey)
? monthKey
: (monthOpts[0]?.value || allMonthOpts[0]?.value || '');
const { people, totalDone } = useMemo(() => { const { people, totalDone } = useMemo(() => {
const [year, month] = monthKey.split('-').map(Number); const [year, month] = effectiveMonthKey.split('-').map(Number);
const map = new Map(); const map = new Map();
(deliveries || []).forEach(d => { (deliveries || []).forEach(d => {
const task = d.submission?.task; const task = d.submission?.task;
@@ -567,35 +527,50 @@ function TeamPerformanceCard({ tasks, profiles, deliveries }) {
if (dt.getFullYear() !== year || dt.getMonth() + 1 !== month) return; if (dt.getFullYear() !== year || dt.getMonth() + 1 !== month) return;
const name = d.sent_by || task.assigned_name; const name = d.sent_by || task.assigned_name;
if (!name) return; if (!name) return;
const entry = map.get(name) || { name, id: task.assigned_to || null, newCount: 0, revCount: 0 }; const matchedProfile = (task.assigned_to && profileMap.get(task.assigned_to)) || profileNameMap.get(String(name).trim().toLowerCase()) || null;
const entry = map.get(name) || {
name,
id: matchedProfile?.id || task.assigned_to || null,
avatar_url: matchedProfile?.avatar_url || '',
newCount: 0,
revCount: 0,
};
if ((d.version_number || 0) === 0) entry.newCount += 1; if ((d.version_number || 0) === 0) entry.newCount += 1;
else entry.revCount += 1; else entry.revCount += 1;
map.set(name, entry); map.set(name, entry);
}); });
const people = [...map.values()].map(p => ({ ...p, done: p.newCount + p.revCount })).sort((a, b) => b.done - a.done); const people = [...map.values()].map(p => ({ ...p, done: p.newCount + p.revCount })).sort((a, b) => b.done - a.done);
return { people, totalDone: people.reduce((s, p) => s + p.done, 0) }; return { people, totalDone: people.reduce((s, p) => s + p.done, 0) };
}, [deliveries, monthKey]); // eslint-disable-line react-hooks/exhaustive-deps }, [deliveries, effectiveMonthKey, profileMap, profileNameMap]); // eslint-disable-line react-hooks/exhaustive-deps
return ( return (
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column' }}> <div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column', height: cardHeight || 'auto', minHeight: cardHeight ? 0 : 280 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, flexShrink: 0 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, flexShrink: 0 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Team Performance</span> <span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Team Performance</span>
<div style={{ position: 'relative', display: 'inline-flex', alignItems: 'center' }}> <div style={{ position: 'relative', display: 'inline-flex', alignItems: 'center' }}>
<select value={monthKey} onChange={e => setMonthKey(e.target.value)} style={{ fontSize: 11, color: 'var(--text-muted)', background: 'transparent', border: 'none', boxShadow: 'none', cursor: 'pointer', outline: 'none', fontFamily: 'inherit', appearance: 'none', WebkitAppearance: 'none', MozAppearance: 'none', paddingRight: 14 }}> <select value={effectiveMonthKey} onChange={e => setMonthKey(e.target.value)} style={{ fontSize: 11, color: 'var(--text-muted)', background: 'transparent', border: 'none', boxShadow: 'none', cursor: 'pointer', outline: 'none', fontFamily: 'inherit', appearance: 'none', WebkitAppearance: 'none', MozAppearance: 'none', paddingRight: 14 }}>
{monthOpts.map(o => <option key={o.value} value={o.value} style={{ background: '#1a1a1a' }}>{o.label}</option>)} {monthOpts.map(o => <option key={o.value} value={o.value} style={{ background: '#1a1a1a' }}>{o.label}</option>)}
</select> </select>
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" style={{ position: 'absolute', right: 0, pointerEvents: 'none' }} stroke="rgba(255,255,255,0.5)" strokeWidth="1.5" strokeLinecap="round"><polyline points="1,2 4,6 7,2"/></svg> <svg width="8" height="8" viewBox="0 0 8 8" fill="none" style={{ position: 'absolute', right: 0, pointerEvents: 'none' }} stroke="rgba(255,255,255,0.5)" strokeWidth="1.5" strokeLinecap="round"><polyline points="1,2 4,6 7,2"/></svg>
</div> </div>
</div> </div>
{people.length === 0 ? ( {people.length === 0 ? (
<div className="card-empty-center">No completed tasks this month</div> <div className="card-empty-center" style={{ flex: 1 }}>No completed tasks this month</div>
) : ( ) : (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}> <div className="table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
{people.slice(0, 5).map((p, i) => { {people.slice(0, 5).map((p, i) => {
const pct = totalDone > 0 ? Math.round((p.done / totalDone) * 100) : 0; const pct = totalDone > 0 ? Math.round((p.done / totalDone) * 100) : 0;
return ( return (
<div key={p.name} style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: i > 0 ? 10 : 0 }}> <div key={p.name} style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: i > 0 ? 10 : 0 }}>
{(() => { const prof = p.id ? profileMap.get(p.id) : null; return prof?.avatar_url ? <div style={{ width: 27, height: 27, borderRadius: '50%', flexShrink: 0, overflow: 'hidden' }}><img src={prof.avatar_url} alt={p.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /></div> : <Avatar name={p.name} />; })()} <ProfileAvatar
profileId={p.id}
name={p.name}
avatarUrl={p.avatar_url}
profilesById={profileMap}
profilesByName={profileNameMap}
size={27}
fontSize={12}
/>
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}> <div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
{p.id ? <button type="button" className="dashboard-inline-link" style={{ fontSize: 13, fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} onClick={() => navigate(`/profile/${p.id}`)}>{p.name}</button> : <span style={{ fontSize: 13, color: 'var(--text-primary)', fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>} {p.id ? <button type="button" className="dashboard-inline-link" style={{ fontSize: 13, fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} onClick={() => navigate(`/profile/${p.id}`)}>{p.name}</button> : <span style={{ fontSize: 13, color: 'var(--text-primary)', fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>}
@@ -628,26 +603,40 @@ export default function TeamDashboard() {
const isClient = currentUser?.role === 'client'; const isClient = currentUser?.role === 'client';
const isExternal = currentUser?.role === 'external'; const isExternal = currentUser?.role === 'external';
const [refreshKey, setRefreshKey] = useState(0); const { refreshKey } = useLiveRefresh(['tasks', 'projects', 'submissions', 'activity_log', 'profiles']);
const refresh = useCallback(() => setRefreshKey(k => k + 1), []);
useRefetchOnFocus(refresh);
useRealtimeSubscription(['tasks', 'projects', 'submissions', 'activity_log', 'profiles'], refresh);
const cached = isTeam ? readPageCache(CACHE_KEY, 5 * 60_000) : null; const cached = isTeam ? readPageCache(CACHE_KEY, 5 * 60_000) : null;
const [tasks, setTasks] = useState(() => cached?.tasks || []); const [tasks, setTasks] = useState(() => cached?.tasks || []);
const [projects, setProjects] = useState(() => cached?.projects || []); const [projects, setProjects] = useState(() => cached?.projects || []);
const [submissions, setSubmissions] = useState(() => cached?.submissions || []); const [submissions, setSubmissions] = useState(() => cached?.submissions || []);
const [allCompanies, setAllCompanies] = useState(() => cached?.companies || []);
const [clientProfiles, setClientProfiles] = useState(() => cached?.clientProfiles || []);
const [companyMemberships, setCompanyMemberships] = useState(() => cached?.companyMemberships || []);
const [activityLog, setActivityLog] = useState(() => cached?.activityLog || []); const [activityLog, setActivityLog] = useState(() => cached?.activityLog || []);
const [allProfiles, setAllProfiles] = useState(() => cached?.allProfiles || []); const [allProfiles, setAllProfiles] = useState(() => cached?.allProfiles || []);
const [teamInvoices, setTeamInvoices] = useState(() => cached?.teamInvoices || []); const [teamInvoices, setTeamInvoices] = useState(() => cached?.teamInvoices || []);
const [teamExpenses, setTeamExpenses] = useState([]); const [teamExpenses, setTeamExpenses] = useState([]);
const [teamSubcontractorPOs, setTeamSubcontractorPOs] = useState([]);
const [teamSubInvoices, setTeamSubInvoices] = useState([]);
const [myInvoices, setMyInvoices] = useState([]); const [myInvoices, setMyInvoices] = useState([]);
const [perfDeliveries, setPerfDeliveries] = useState([]); const [perfDeliveries, setPerfDeliveries] = useState([]);
const [loading, setLoading] = useState(!cached); const [loading, setLoading] = useState(!cached);
const bottomCardsRowRef = useRef(null);
const [bottomCardsHeight, setBottomCardsHeight] = useState(null);
useEffect(() => {
function updateBottomCardsHeight() {
const row = bottomCardsRowRef.current;
if (!row) return;
const rect = row.getBoundingClientRect();
const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0;
const available = Math.floor(viewportHeight - rect.top - 24);
const nextHeight = available > 0 ? `${available}px` : null;
setBottomCardsHeight(prev => (prev === nextHeight ? prev : nextHeight));
}
updateBottomCardsHeight();
window.addEventListener('resize', updateBottomCardsHeight);
return () => window.removeEventListener('resize', updateBottomCardsHeight);
}, [loading, isTeam, isClient, isExternal]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -658,37 +647,16 @@ export default function TeamDashboard() {
cutoff.setMonth(cutoff.getMonth() - CUTOFF_MONTHS); cutoff.setMonth(cutoff.getMonth() - CUTOFF_MONTHS);
const cutoffStr = cutoff.toISOString(); const cutoffStr = cutoff.toISOString();
let scopedTaskIds = null; const {
let scopedProjectIds = null; scopedTaskIds,
let scopedCompanyIds = null; scopedProjectIds,
scopedCompanyIds,
if (!isTeam && isClient) { } = isTeam
const companyIds = [...new Set([...(currentUser?.companies?.map(c => c.company?.id || c.id) || []), ...(currentUser?.company_id ? [currentUser.company_id] : [])])].filter(Boolean); ? { scopedTaskIds: null, scopedProjectIds: null, scopedCompanyIds: null }
scopedCompanyIds = companyIds; : await resolveScopedWorkIds(currentUser, { isClient, isExternal });
if (companyIds.length > 0) {
const { data: projRows } = await supabase.from('projects').select('id, tasks(id)').in('company_id', companyIds);
scopedProjectIds = (projRows || []).map(p => p.id).filter(Boolean);
scopedTaskIds = (projRows || []).flatMap(p => (p.tasks || []).map(t => t.id)).filter(Boolean);
} else {
scopedProjectIds = [];
scopedTaskIds = [];
}
}
if (!isTeam && isExternal) {
const uid = currentUser?.id;
const { data: memberData } = await supabase.from('project_members').select('project_id').eq('profile_id', uid);
scopedProjectIds = (memberData || []).map(m => m.project_id).filter(Boolean);
if ((scopedProjectIds || []).length > 0) {
const { data: projectTasks } = await supabase.from('tasks').select('id').in('project_id', scopedProjectIds);
scopedTaskIds = (projectTasks || []).map(row => row.id).filter(Boolean);
} else {
scopedTaskIds = [];
}
}
const tasksQuery = supabase.from('tasks') const tasksQuery = supabase.from('tasks')
.select('id, title, status, current_version, project_id, assigned_name, assigned_to, completed_at, assignee:profiles!assigned_to(avatar_url)') .select('id, title, status, current_version, project_id, assigned_name, assigned_to, completed_at, project:projects(name), assignee:profiles!assigned_to(avatar_url)')
.gte('submitted_at', cutoffStr) .gte('submitted_at', cutoffStr)
.order('submitted_at', { ascending: false }); .order('submitted_at', { ascending: false });
if (isClient) { if (isClient) {
@@ -724,10 +692,10 @@ export default function TeamDashboard() {
} }
const invoicesPromise = isTeam const invoicesPromise = isTeam
? supabase.from('invoices').select('total, status, company_id, created_at').in('status', ['sent', 'paid']) ? supabase.from('invoices').select('total, stripe_fee, status, company_id, created_at').in('status', ['sent', 'paid'])
: isClient : isClient
? ((scopedCompanyIds || []).length > 0 ? ((scopedCompanyIds || []).length > 0
? supabase.from('invoices').select('total, status, company_id, created_at').in('company_id', scopedCompanyIds).in('status', ['sent', 'paid']) ? supabase.from('invoices').select('total, stripe_fee, status, company_id, created_at').in('company_id', scopedCompanyIds).in('status', ['sent', 'paid'])
: Promise.resolve({ data: [] })) : Promise.resolve({ data: [] }))
: supabase.from('subcontractor_invoices').select('total, status').eq('profile_id', currentUser?.id).in('status', ['submitted', 'approved', 'paid']); : supabase.from('subcontractor_invoices').select('total, status').eq('profile_id', currentUser?.id).in('status', ['submitted', 'approved', 'paid']);
@@ -735,17 +703,25 @@ export default function TeamDashboard() {
? supabase.from('expenses').select('amount') ? supabase.from('expenses').select('amount')
: Promise.resolve({ data: [] }); : Promise.resolve({ data: [] });
const [{ data: t }, { data: p }, { data: subs }, { data: profiles }, { data: activity }, { data: cos }, { data: memRows }, { data: invs }, { data: exps }, { data: delivs }] = await withTimeout(Promise.all([ const subcontractorPOsPromise = isTeam
? supabase.from('subcontractor_payments').select('amount, status, paid_at, date')
: Promise.resolve({ data: [] });
const subcontractorInvoicesPromise = isTeam
? supabase.from('subcontractor_invoices').select('status, paid_at, items:subcontractor_invoice_items(unit_price, quantity)')
: Promise.resolve({ data: [] });
const [{ data: t }, { data: p }, { data: subs }, { data: profiles }, { data: activity }, { data: invs }, { data: exps }, { data: subcontractorPOs }, { data: subcontractorInvoices }, { data: delivs }] = await withTimeout(Promise.all([
tasksQuery, tasksQuery,
projectsQuery, projectsQuery,
submissionsQuery, submissionsQuery,
supabase.from('profiles').select('id, role, name, email, company_id, brand_book_rate, avatar_url'), supabase.from('profiles').select('id, role, name, email, company_id, brand_book_rate, avatar_url'),
supabase.from('activity_log').select('id, created_at, actor_id, actor_name, action, task_id, task_title, project_name, project_id').order('created_at', { ascending: false }).limit(isTeam ? 20 : 50), supabase.from('activity_log').select('id, created_at, actor_id, actor_name, action, task_id, task_title, project_name, project_id').order('created_at', { ascending: false }).limit(isTeam ? 20 : 50),
supabase.from('companies').select('id, name').order('name'),
supabase.from('company_members').select('company_id, profile_id'),
invoicesPromise, invoicesPromise,
expensesPromise, expensesPromise,
supabase.from('deliveries').select('sent_by, sent_at, version_number, submission:submissions!inner(task:tasks!inner(assigned_to, assigned_name))').gte('sent_at', cutoffStr), subcontractorPOsPromise,
subcontractorInvoicesPromise,
supabase.from('deliveries').select('sent_by, sent_at, version_number, submission:submissions!inner(task_id, task:tasks!inner(assigned_to, assigned_name))').gte('sent_at', cutoffStr),
]), 30000, 'Dashboard load'); ]), 30000, 'Dashboard load');
if (cancelled) return; if (cancelled) return;
@@ -754,14 +730,14 @@ export default function TeamDashboard() {
const roleById = new Map((profiles || []).map(pr => [pr.id, pr.role])); const roleById = new Map((profiles || []).map(pr => [pr.id, pr.role]));
const roleByName = new Map((profiles || []).map(pr => [pr.name, pr.role])); const roleByName = new Map((profiles || []).map(pr => [pr.name, pr.role]));
const tasksWithDeadlines = (t || []).map(task => ({ ...task, deadline: getDeadlineSourceSubmission(task, subs)?.deadline || null, assignee_role: roleById.get(task.assigned_to) || null })); const tasksWithDeadlines = (t || []).map(task => ({ ...task, deadline: getDeadlineSourceSubmission(task, subs)?.deadline || null, assignee_role: roleById.get(task.assigned_to) || null }));
const subsWithRole = (subs || []).map(sub => ({ ...sub, submitter_role: roleById.get(sub.submitted_by) || null, delivery_sender_role: roleByName.get(sub.delivery?.sent_by) || null })); const subsWithRole = mergeSubmissionDisplayNames(
(subs || []).map(sub => ({ ...sub, submitter_role: roleById.get(sub.submitted_by) || null, delivery_sender_role: roleByName.get(sub.delivery?.sent_by) || null })),
profiles || []
);
setTasks(tasksWithDeadlines); setTasks(tasksWithDeadlines);
setProjects(p || []); setProjects(p || []);
setSubmissions(subsWithRole); setSubmissions(subsWithRole);
setClientProfiles((profiles || []).filter(pr => pr.role === 'client'));
setAllProfiles(profiles || []); setAllProfiles(profiles || []);
setAllCompanies(cos || []);
setCompanyMemberships(memRows || []);
const scopedActivity = isTeam const scopedActivity = isTeam
? (activity || []) ? (activity || [])
: (activity || []).filter((entry) => { : (activity || []).filter((entry) => {
@@ -773,16 +749,19 @@ export default function TeamDashboard() {
setTeamInvoices(isTeam ? (invs || []) : []); setTeamInvoices(isTeam ? (invs || []) : []);
setMyInvoices(!isTeam ? (invs || []) : []); setMyInvoices(!isTeam ? (invs || []) : []);
setTeamExpenses(exps || []); setTeamExpenses(exps || []);
setPerfDeliveries(delivs || []); setTeamSubcontractorPOs(subcontractorPOs || []);
setTeamSubInvoices(subcontractorInvoices || []);
const scopedDeliveryTaskIds = new Set(scopedTaskIds || []);
const scopedDeliveries = isTeam
? (delivs || [])
: (delivs || []).filter(delivery => scopedDeliveryTaskIds.has(delivery.submission?.task_id));
setPerfDeliveries(scopedDeliveries);
if (isTeam) { if (isTeam) {
writePageCache(CACHE_KEY, { writePageCache(CACHE_KEY, {
tasks: tasksWithDeadlines, tasks: tasksWithDeadlines,
projects: p || [], projects: p || [],
submissions: subsWithRole, submissions: subsWithRole,
clientProfiles: (profiles || []).filter(pr => pr.role === 'client'),
companies: cos || [],
companyMemberships: memRows || [],
activityLog: activity || [], activityLog: activity || [],
teamInvoices: invs || [], teamInvoices: invs || [],
allProfiles: profiles || [], allProfiles: profiles || [],
@@ -797,12 +776,6 @@ export default function TeamDashboard() {
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [isTeam, isClient, isExternal, currentUser?.id, currentUser?.company_id, currentUser?.companies, refreshKey]); // eslint-disable-line react-hooks/exhaustive-deps }, [isTeam, isClient, isExternal, currentUser?.id, currentUser?.company_id, currentUser?.companies, refreshKey]); // eslint-disable-line react-hooks/exhaustive-deps
const teamHighlights = useMemo(() =>
isTeam ? buildClientHighlights(allCompanies, projects, tasks, clientProfiles, companyMemberships, teamInvoices)
.sort((a, b) => b.openTaskCount - a.openTaskCount || b.projectCount - a.projectCount || a.company.name.localeCompare(b.company.name))
.slice(0, 5) : [],
[isTeam, allCompanies, projects, tasks, clientProfiles, companyMemberships, teamInvoices]);
const activityEvents = useMemo(() => const activityEvents = useMemo(() =>
(activityLog || []).map(e => ({ (activityLog || []).map(e => ({
time: new Date(e.created_at), time: new Date(e.created_at),
@@ -817,7 +790,7 @@ export default function TeamDashboard() {
})).filter(e => !isNaN(e.time)).slice(0, 10), })).filter(e => !isNaN(e.time)).slice(0, 10),
[activityLog]); [activityLog]);
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>; if (loading) return <Layout><PageLoader /></Layout>;
const doneStatuses = ['client_approved', 'invoiced', 'paid']; const doneStatuses = ['client_approved', 'invoiced', 'paid'];
const inProgressTasks = tasks.filter(t => t.status === 'in_progress'); const inProgressTasks = tasks.filter(t => t.status === 'in_progress');
@@ -839,7 +812,18 @@ export default function TeamDashboard() {
}); });
const dashRevenue = teamInvoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total || 0), 0); const dashRevenue = teamInvoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total || 0), 0);
const dashOutstanding = teamInvoices.filter(i => i.status === 'sent').reduce((s, i) => s + Number(i.total || 0), 0); const dashOutstanding = teamInvoices.filter(i => i.status === 'sent').reduce((s, i) => s + Number(i.total || 0), 0);
const dashExpensesTotal = teamExpenses.reduce((s, e) => s + Number(e.amount || 0), 0); const dashOpsExpensesTotal = teamExpenses.reduce((s, e) => s + Number(e.amount || 0), 0);
const dashNetReceived = teamInvoices
.filter(i => i.status === 'paid')
.reduce((s, i) => s + Number(i.total || 0) - Number(i.stripe_fee || 0), 0);
const dashPaidSubcontractorPOsTotal = teamSubcontractorPOs
.filter(po => po.status === 'paid')
.reduce((s, po) => s + Number(po.amount || 0), 0);
const dashPaidSubInvoicesTotal = teamSubInvoices
.filter(inv => inv.status === 'paid')
.reduce((s, inv) => s + (inv.items || []).reduce((a, item) => a + Number(item.unit_price || 0) * Number(item.quantity || 1), 0), 0);
const dashSubcontractorExpensesTotal = dashPaidSubcontractorPOsTotal + dashPaidSubInvoicesTotal;
const dashExpensesTotal = dashOpsExpensesTotal + dashSubcontractorExpensesTotal;
const revenueByMonth = Array.from({ length: 4 }, (_, i) => { const revenueByMonth = Array.from({ length: 4 }, (_, i) => {
const now = new Date(); const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth() - (3 - i), 1); const start = new Date(now.getFullYear(), now.getMonth() - (3 - i), 1);
@@ -851,7 +835,7 @@ export default function TeamDashboard() {
const myOutstanding = myInvoices.filter(i => i.status !== 'paid').reduce((s, i) => s + Number(i.total || 0), 0); const myOutstanding = myInvoices.filter(i => i.status !== 'paid').reduce((s, i) => s + Number(i.total || 0), 0);
const card3 = isTeam const card3 = isTeam
? { label: 'Net Profit', value: fmtMoney(dashRevenue - dashExpensesTotal), sub: `${fmtMoney(dashExpensesTotal)} expenses`, iconBg: 'rgba(74,222,128,0.15)', iconColor: '#4ade80', iconPath: DASH_ICONS.profit } ? { label: 'Net Profit', value: fmtMoney(dashNetReceived - dashExpensesTotal), sub: `${fmtMoney(dashExpensesTotal)} expenses + sub costs`, iconBg: 'rgba(74,222,128,0.15)', iconColor: '#4ade80', iconPath: DASH_ICONS.profit }
: isClient : isClient
? { label: 'Outstanding', value: fmtMoney(myOutstanding), sub: 'invoices due', iconBg: 'rgba(245,165,35,0.15)', iconColor: '#F5A523', iconPath: DASH_ICONS.revenue } ? { label: 'Outstanding', value: fmtMoney(myOutstanding), sub: 'invoices due', iconBg: 'rgba(245,165,35,0.15)', iconColor: '#F5A523', iconPath: DASH_ICONS.revenue }
: { label: 'Pending', value: fmtMoney(myOutstanding), sub: 'awaiting payment', iconBg: 'rgba(245,165,35,0.15)', iconColor: '#F5A523', iconPath: DASH_ICONS.revenue }; : { label: 'Pending', value: fmtMoney(myOutstanding), sub: 'awaiting payment', iconBg: 'rgba(245,165,35,0.15)', iconColor: '#F5A523', iconPath: DASH_ICONS.revenue };
@@ -875,15 +859,10 @@ export default function TeamDashboard() {
<TasksInProgressCard tasks={inProgressTasks} /> <TasksInProgressCard tasks={inProgressTasks} />
<MiniCalendar items={calendarItems} /> <MiniCalendar items={calendarItems} />
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginTop: 24 }}> <div ref={bottomCardsRowRef} style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginTop: 24 }}>
<HotItemsCard submissions={submissions} tasks={tasks} isClient={isClient} /> <HotItemsCard submissions={submissions} tasks={tasks} isClient={isClient} isExternal={isExternal} currentUserId={currentUser?.id || null} cardHeight={bottomCardsHeight} />
<TeamPerformanceCard tasks={tasks} profiles={allProfiles} deliveries={perfDeliveries} /> <TeamPerformanceCard tasks={tasks} profiles={allProfiles} deliveries={perfDeliveries} cardHeight={bottomCardsHeight} />
</div> </div>
{isTeam && (
<div style={{ marginTop: 24 }}>
<ClientHighlightCard highlights={teamHighlights} />
</div>
)}
</Layout> </Layout>
); );
} }
+4 -17
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import Layout from '../../components/Layout'; import Layout from '../../components/Layout';
import { supabase } from '../../lib/supabase'; import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/AuthContext';
import { formatShortDateTime } from '../../lib/dates';
function emptyForm() { function emptyForm() {
return { return {
@@ -17,17 +18,6 @@ function sortEntries(entries) {
return [...entries].sort((a, b) => a.service_name.localeCompare(b.service_name, undefined, { sensitivity: 'base' })); return [...entries].sort((a, b) => a.service_name.localeCompare(b.service_name, undefined, { sensitivity: 'base' }));
} }
function formatDate(value) {
if (!value) return 'Unknown';
return new Date(value).toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}
function normalizeUrl(value) { function normalizeUrl(value) {
const raw = String(value || '').trim(); const raw = String(value || '').trim();
if (!raw) return ''; if (!raw) return '';
@@ -253,7 +243,7 @@ export default function FourgePasswords() {
} }
return ( return (
<Layout> <Layout loading={loading}>
<div className="page-header"> <div className="page-header">
<div> <div>
<div className="page-title">Fourge Passwords</div> <div className="page-title">Fourge Passwords</div>
@@ -294,10 +284,7 @@ export default function FourgePasswords() {
{loading ? ( {loading ? (
<div style={{ color: 'var(--text-muted)' }}>Loading passwords...</div> <div style={{ color: 'var(--text-muted)' }}>Loading passwords...</div>
) : entries.length === 0 ? ( ) : entries.length === 0 ? (
<div className="empty-state" style={{ padding: '28px 20px' }}> <div className="card-empty-center" style={{ minHeight: 120 }}>No passwords</div>
<h3 style={{ marginBottom: 8 }}>No passwords yet</h3>
<p>Add encrypted password entries for internal services and team access.</p>
</div>
) : ( ) : (
<div style={{ display: 'grid', gap: 10 }}> <div style={{ display: 'grid', gap: 10 }}>
{entries.map(entry => ( {entries.map(entry => (
@@ -316,7 +303,7 @@ export default function FourgePasswords() {
<div> <div>
<div style={{ fontSize: 15, fontWeight: 400, color: 'var(--text-primary)' }}>{entry.service_name}</div> <div style={{ fontSize: 15, fontWeight: 400, color: 'var(--text-primary)' }}>{entry.service_name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 4 }}> <div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 4 }}>
Updated {formatDate(entry.updated_at || entry.created_at)} Updated {formatShortDateTime(entry.updated_at || entry.created_at, 'Unknown')}
</div> </div>
</div> </div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
+113 -36
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useParams, useNavigate, useLocation, Link } from 'react-router-dom'; import { useParams, useNavigate, useLocation, Link } from 'react-router-dom';
import Layout from '../../components/Layout'; import Layout from '../../components/Layout';
import PageLoader from '../../components/PageLoader';
import LoadingButton from '../../components/LoadingButton'; import LoadingButton from '../../components/LoadingButton';
import SortTh from '../../components/SortTh'; import SortTh from '../../components/SortTh';
import StatusBadge from '../../components/StatusBadge'; import StatusBadge from '../../components/StatusBadge';
@@ -9,15 +10,20 @@ import { generateInvoicePDF, generateReceiptPDF } from '../../lib/invoice';
import { blobToEmailAttachment, sendEmail } from '../../lib/email'; import { blobToEmailAttachment, sendEmail } from '../../lib/email';
import { withTimeout } from '../../lib/withTimeout'; import { withTimeout } from '../../lib/withTimeout';
import { useSortable } from '../../hooks/useSortable'; import { useSortable } from '../../hooks/useSortable';
import { popupOverlayStyle, popupSurfaceStyle } from '../../lib/popupStyles';
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' }; const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
export default function InvoiceDetail() { export function TeamInvoiceDetailPanel({
const { id } = useParams(); invoiceId,
initialInvoice = null,
embedded = false,
onClose,
onInvoiceUpdated,
onInvoiceDeleted,
}) {
const navigate = useNavigate(); const navigate = useNavigate();
const { state } = useLocation(); const [invoice, setInvoice] = useState(initialInvoice || null);
const [invoice, setInvoice] = useState(state?.invoice || null);
const [company, setCompany] = useState(null); const [company, setCompany] = useState(null);
const [companies, setCompanies] = useState([]); const [companies, setCompanies] = useState([]);
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
@@ -32,14 +38,14 @@ export default function InvoiceDetail() {
useEffect(() => { useEffect(() => {
async function load() { async function load() {
try { try {
const { data: inv } = await supabase.from('invoices').select('*').eq('id', id).single(); const { data: inv } = await supabase.from('invoices').select('*').eq('id', invoiceId).single();
if (!inv) return; if (!inv) return;
setInvoice(inv); setInvoice(inv);
const [{ data: co }, { data: companyList }, { data: its }] = await Promise.all([ const [{ data: co }, { data: companyList }, { data: its }] = await Promise.all([
supabase.from('companies').select('*').eq('id', inv.company_id).single(), supabase.from('companies').select('*').eq('id', inv.company_id).single(),
supabase.from('companies').select('*').order('name'), supabase.from('companies').select('*').order('name'),
supabase.from('invoice_items').select('*').eq('invoice_id', id).order('created_at'), supabase.from('invoice_items').select('*').eq('invoice_id', invoiceId).order('created_at'),
]); ]);
const defaultEmail = inv.invoice_email || await getDefaultInvoiceEmail(inv.company_id, co); const defaultEmail = inv.invoice_email || await getDefaultInvoiceEmail(inv.company_id, co);
setCompany(co); setCompany(co);
@@ -53,23 +59,47 @@ export default function InvoiceDetail() {
} }
} }
load(); load();
}, [id]); }, [invoiceId]);
const syncInvoiceState = (updater) => {
setInvoice((current) => {
const next = typeof updater === 'function' ? updater(current) : updater;
onInvoiceUpdated?.(next);
return next;
});
};
const updateStatus = async (status) => { const updateStatus = async (status) => {
setSaving(true); setSaving(true);
const updates = { status }; const updates = { status };
if (status === 'paid' && !invoice.paid_at) updates.paid_at = new Date().toISOString(); if (status === 'paid' && !invoice.paid_at) updates.paid_at = new Date().toISOString();
if (status !== 'paid') updates.paid_at = null; if (status !== 'paid') updates.paid_at = null;
const { error } = await supabase.from('invoices').update(updates).eq('id', id); const { error } = await supabase.from('invoices').update(updates).eq('id', invoiceId);
if (!error) { if (!error) {
setInvoice(i => ({ ...i, ...updates })); syncInvoiceState(i => ({ ...i, ...updates }));
// Sync task statuses along invoice lifecycle // Sync task statuses along invoice lifecycle
const { data: freshItems } = await supabase.from('invoice_items').select('task_id, submission_id').eq('invoice_id', id); const { data: freshItems } = await supabase.from('invoice_items').select('task_id, submission_id').eq('invoice_id', invoiceId);
const taskIds = (freshItems || []).filter(i => i.task_id && !i.submission_id).map(i => i.task_id); const taskIds = (freshItems || []).filter(i => i.task_id && !i.submission_id).map(i => i.task_id);
if (taskIds.length > 0) { if (taskIds.length > 0) {
const newTaskStatus = status === 'paid' ? 'paid' : status === 'sent' ? 'invoiced' : 'client_approved'; const newTaskStatus = status === 'paid' ? 'paid' : status === 'sent' ? 'invoiced' : 'client_approved';
await supabase.from('tasks').update({ status: newTaskStatus }).in('id', taskIds); await supabase.from('tasks').update({ status: newTaskStatus }).in('id', taskIds);
} }
if (status === 'paid') {
try {
const contactEmail = invoice.invoice_email || await getDefaultInvoiceEmail(invoice.company_id, company);
if (contactEmail) {
const paidDate = new Date(updates.paid_at || new Date().toISOString()).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
await sendEmail('receipt_sent', [contactEmail], {
invoiceNumber: invoice.invoice_number,
billTo: invoice.bill_to || company?.name,
total: `$${Number(invoice.total).toFixed(2)}`,
paidDate,
});
}
} catch (emailErr) {
console.error('Failed to send paid invoice email:', emailErr);
}
}
} else { } else {
alert('Failed to update status.'); alert('Failed to update status.');
} }
@@ -104,12 +134,12 @@ export default function InvoiceDetail() {
const contactEmail = getEmailRecipient(); const contactEmail = getEmailRecipient();
if (!contactEmail) throw new Error('Enter an email recipient before sending.'); if (!contactEmail) throw new Error('Enter an email recipient before sending.');
const { error } = await withTimeout( const { error } = await withTimeout(
supabase.from('invoices').update({ invoice_email: contactEmail }).eq('id', id), supabase.from('invoices').update({ invoice_email: contactEmail }).eq('id', invoiceId),
12000, 12000,
'Saving invoice email' 'Saving invoice email'
); );
if (error) throw error; if (error) throw error;
setInvoice(inv => ({ ...inv, invoice_email: contactEmail })); syncInvoiceState(inv => ({ ...inv, invoice_email: contactEmail }));
return contactEmail; return contactEmail;
}; };
@@ -167,12 +197,12 @@ export default function InvoiceDetail() {
const updates = { status: 'sent' }; const updates = { status: 'sent' };
const { error } = await withTimeout( const { error } = await withTimeout(
supabase.from('invoices').update(updates).eq('id', id), supabase.from('invoices').update(updates).eq('id', invoiceId),
12000, 12000,
'Updating invoice status' 'Updating invoice status'
); );
if (error) throw error; if (error) throw error;
setInvoice(i => ({ ...i, ...updates })); syncInvoiceState(i => ({ ...i, ...updates }));
alert(`Invoice email sent successfully.${attachmentWarning || ''}`); alert(`Invoice email sent successfully.${attachmentWarning || ''}`);
} catch (err) { } catch (err) {
console.error('Failed to finalize and send invoice:', err); console.error('Failed to finalize and send invoice:', err);
@@ -202,16 +232,22 @@ export default function InvoiceDetail() {
}; };
const handleDelete = async () => { const handleDelete = async () => {
if (invoice?.status === 'paid') {
alert('Paid invoices cannot be deleted.');
return;
}
setSaving(true); setSaving(true);
try { try {
const { data: freshItems } = await supabase.from('invoice_items').select('task_id, submission_id').eq('invoice_id', id); const { data: freshItems } = await supabase.from('invoice_items').select('task_id, submission_id').eq('invoice_id', invoiceId);
const taskIds = (freshItems || []).filter(i => i.task_id && !i.submission_id).map(i => i.task_id); const taskIds = (freshItems || []).filter(i => i.task_id && !i.submission_id).map(i => i.task_id);
if (taskIds.length > 0) await supabase.from('tasks').update({ invoiced: false, status: 'client_approved' }).in('id', taskIds); if (taskIds.length > 0) await supabase.from('tasks').update({ invoiced: false, status: 'client_approved' }).in('id', taskIds);
const submissionIds = (freshItems || []).filter(i => i.submission_id).map(i => i.submission_id); const submissionIds = (freshItems || []).filter(i => i.submission_id).map(i => i.submission_id);
if (submissionIds.length > 0) await supabase.from('submissions').update({ invoiced: false }).in('id', submissionIds); if (submissionIds.length > 0) await supabase.from('submissions').update({ invoiced: false }).in('id', submissionIds);
const { error } = await supabase.from('invoices').delete().eq('id', id); const { error } = await supabase.from('invoices').delete().eq('id', invoiceId);
if (error) throw error; if (error) throw error;
navigate('/invoices'); onInvoiceDeleted?.(invoiceId);
if (embedded) onClose?.();
else navigate('/finances');
} catch { } catch {
alert('Failed to delete invoice. Please try again.'); alert('Failed to delete invoice. Please try again.');
setSaving(false); setSaving(false);
@@ -279,8 +315,8 @@ export default function InvoiceDetail() {
await supabase.from('invoices').update({ await supabase.from('invoices').update({
invoice_date: dateForm.invoice_date, invoice_date: dateForm.invoice_date,
due_date: dateForm.due_date, due_date: dateForm.due_date,
}).eq('id', id); }).eq('id', invoiceId);
setInvoice(i => ({ ...i, invoice_date: dateForm.invoice_date, due_date: dateForm.due_date })); syncInvoiceState(i => ({ ...i, invoice_date: dateForm.invoice_date, due_date: dateForm.due_date }));
setEditingDates(false); setEditingDates(false);
setSaving(false); setSaving(false);
}; };
@@ -288,8 +324,8 @@ export default function InvoiceDetail() {
const handleEmailBlur = async () => { const handleEmailBlur = async () => {
const nextEmail = getEmailRecipient(); const nextEmail = getEmailRecipient();
if ((invoice.invoice_email || '') === nextEmail) return; if ((invoice.invoice_email || '') === nextEmail) return;
const { error } = await supabase.from('invoices').update({ invoice_email: nextEmail || null }).eq('id', id); const { error } = await supabase.from('invoices').update({ invoice_email: nextEmail || null }).eq('id', invoiceId);
if (!error) setInvoice(inv => ({ ...inv, invoice_email: nextEmail || null })); if (!error) syncInvoiceState(inv => ({ ...inv, invoice_email: nextEmail || null }));
}; };
const handleCompanyChange = async (companyId) => { const handleCompanyChange = async (companyId) => {
@@ -302,19 +338,37 @@ export default function InvoiceDetail() {
company_id: companyId, company_id: companyId,
bill_to: nextCompany.name, bill_to: nextCompany.name,
invoice_email: defaultEmail, invoice_email: defaultEmail,
}).eq('id', id); }).eq('id', invoiceId);
setSaving(false); setSaving(false);
if (error) { if (error) {
alert('Failed to update invoice company. Please try again.'); alert('Failed to update invoice company. Please try again.');
return; return;
} }
setInvoice(inv => ({ ...inv, company_id: companyId, bill_to: nextCompany.name, invoice_email: defaultEmail })); syncInvoiceState(inv => ({ ...inv, company_id: companyId, bill_to: nextCompany.name, invoice_email: defaultEmail }));
setCompany(nextCompany); setCompany(nextCompany);
setEmailRecipient(defaultEmail); setEmailRecipient(defaultEmail);
}; };
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>; if (loading) {
if (!invoice) return <Layout><p>Invoice not found.</p></Layout>; if (embedded) {
return (
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<PageLoader />
</div>
);
}
return <Layout><PageLoader /></Layout>;
}
if (!invoice) {
if (embedded) {
return (
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-muted)' }}>
Invoice not found.
</div>
);
}
return <Layout><p>Invoice not found.</p></Layout>;
}
const sortedItems = sort(items, (item, key) => { const sortedItems = sort(items, (item, key) => {
if (key === 'type') return item.submission_id ? 'Revision' : 'New'; if (key === 'type') return item.submission_id ? 'Revision' : 'New';
if (key === 'description') return item.description || ''; if (key === 'description') return item.description || '';
@@ -325,19 +379,18 @@ export default function InvoiceDetail() {
}); });
const isOverdue = invoice.status !== 'paid' && new Date(invoice.due_date) < new Date(); const isOverdue = invoice.status !== 'paid' && new Date(invoice.due_date) < new Date();
const detailContent = (
<>
{!embedded && <button className="back-link" onClick={() => navigate('/finances')}> Back to Invoices</button>}
return ( <div className={embedded ? '' : 'page-header'} style={embedded ? { display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16, paddingBottom: 18, borderBottom: '1px solid var(--border)' } : undefined}>
<Layout>
<button className="back-link" onClick={() => navigate('/invoices')}> Back to Invoices</button>
<div className="page-header">
<div> <div>
<div className="page-title">{invoice.invoice_number}</div> <div className={embedded ? '' : 'page-title'} style={embedded ? { fontSize: 18, fontWeight: 500, color: 'var(--text-primary)', letterSpacing: 0.2, lineHeight: 1.1 } : undefined}>{invoice.invoice_number}</div>
<div className="page-subtitle"> <div className={embedded ? '' : 'page-subtitle'} style={embedded ? { marginTop: 6, fontSize: 13, color: 'var(--text-secondary)' } : undefined}>
<Link to={`/company/${company?.id}`} style={{ color: 'var(--accent)' }}>{company?.name}</Link> <Link to={`/company/${company?.id}`} style={{ color: 'var(--accent)' }}>{company?.name}</Link>
</div> </div>
</div> </div>
<div className="action-buttons"> <div className="action-buttons" style={embedded ? { justifyContent: 'flex-end' } : undefined}>
<StatusBadge <StatusBadge
status={statusColor[invoice.status] || 'not_started'} status={statusColor[invoice.status] || 'not_started'}
label={`${invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—'}${isOverdue ? ' · Overdue' : ''}`} label={`${invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—'}${isOverdue ? ' · Overdue' : ''}`}
@@ -355,7 +408,7 @@ export default function InvoiceDetail() {
</div> </div>
</div> </div>
<div className="grid-2" style={{ marginBottom: 24 }}> <div className="grid-2" style={{ marginTop: embedded ? 18 : 0, marginBottom: 24 }}>
<div className="card"> <div className="card">
<div className="card-title">Bill To</div> <div className="card-title">Bill To</div>
<div style={{ fontSize: 15, fontWeight: 400 }}>{invoice.bill_to || company?.name}</div> <div style={{ fontSize: 15, fontWeight: 400 }}>{invoice.bill_to || company?.name}</div>
@@ -510,9 +563,33 @@ export default function InvoiceDetail() {
</LoadingButton> </LoadingButton>
</> </>
)} )}
{invoice.status !== 'paid' && (
<button className="btn-icon btn-icon-danger" title="Delete Invoice" onClick={handleDelete} disabled={saving}></button> <button className="btn-icon btn-icon-danger" title="Delete Invoice" onClick={handleDelete} disabled={saving}></button>
)}
</div>
</div>
</>
);
if (embedded) {
return (
<div style={popupOverlayStyle} onClick={() => { if (!saving && !generating) onClose?.(); }}>
<div
style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0, overflowY: 'auto' }}
onClick={(e) => e.stopPropagation()}
>
{detailContent}
</div> </div>
</div> </div>
</Layout>
); );
} }
return <Layout>{detailContent}</Layout>;
}
export default function InvoiceDetail() {
const { id } = useParams();
const { state } = useLocation();
return <TeamInvoiceDetailPanel invoiceId={id} initialInvoice={state?.invoice || null} />;
}
+456 -79
View File
@@ -7,6 +7,7 @@ import SortTh from '../../components/SortTh';
import StatusBadge from '../../components/StatusBadge'; import StatusBadge from '../../components/StatusBadge';
import { useSortable } from '../../hooks/useSortable'; import { useSortable } from '../../hooks/useSortable';
import { supabase } from '../../lib/supabase'; import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext';
import { readPageCache, writePageCache } from '../../lib/pageCache'; import { readPageCache, writePageCache } from '../../lib/pageCache';
import { exportCPAPackage, generateSubcontractorPOPDF, generateInvoicePDF } from '../../lib/invoice'; import { exportCPAPackage, generateSubcontractorPOPDF, generateInvoicePDF } from '../../lib/invoice';
import { withTimeout } from '../../lib/withTimeout'; import { withTimeout } from '../../lib/withTimeout';
@@ -14,6 +15,9 @@ import LoadingButton from '../../components/LoadingButton';
import { blobToEmailAttachment, sendEmail } from '../../lib/email'; import { blobToEmailAttachment, sendEmail } from '../../lib/email';
import { popupOverlayStyle, popupSurfaceStyle } from '../../lib/popupStyles'; import { popupOverlayStyle, popupSurfaceStyle } from '../../lib/popupStyles';
import FileAttachment from '../../components/FileAttachment'; import FileAttachment from '../../components/FileAttachment';
import { isReviewShadowDescription } from '../../lib/taskVersions';
import { getRevisionChargeQuantity, isCompletedVersionEligible, isInitialVersionEligible } from '../../lib/invoiceVersionRules';
import { TeamInvoiceDetailPanel } from './TeamInvoiceDetail';
const CATEGORIES = ['Software', 'Contractor', 'Advertising', 'Office', 'Travel', 'Meals', 'Equipment', 'Other']; const CATEGORIES = ['Software', 'Contractor', 'Advertising', 'Office', 'Travel', 'Meals', 'Equipment', 'Other'];
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' }; const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
@@ -40,12 +44,156 @@ const invoiceStatusBadgeLabel = (status) => {
const RECEIPT_BUCKET = 'expense-receipts'; const RECEIPT_BUCKET = 'expense-receipts';
const FIELD_LABEL_STYLE = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, display: 'block', marginBottom: 4 }; const FIELD_LABEL_STYLE = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, display: 'block', marginBottom: 4 };
const FIELD_INPUT_STYLE = { minHeight: 42, margin: 0 }; const FIELD_INPUT_STYLE = { minHeight: 42, margin: 0 };
const FINANCE_MODAL_INPUT_STYLE = {
...FIELD_INPUT_STYLE,
width: '100%',
padding: '0 10px',
fontSize: 13,
color: 'var(--text-primary)',
background: 'var(--card-bg-2)',
border: '1px solid var(--border)',
borderRadius: 6,
fontFamily: 'inherit',
boxSizing: 'border-box',
};
const FINANCE_MODAL_TEXTAREA_STYLE = {
...FINANCE_MODAL_INPUT_STYLE,
minHeight: 72,
padding: '10px',
resize: 'vertical',
};
const FINANCE_MODAL_LINE_ITEM_INPUT_STYLE = {
...FINANCE_MODAL_INPUT_STYLE,
minHeight: 32,
padding: '0 8px',
};
const FINANCE_MODAL_NUMBER_INPUT_STYLE = {
...FINANCE_MODAL_LINE_ITEM_INPUT_STYLE,
fontVariantNumeric: 'tabular-nums',
};
const FINANCE_MODAL_AMOUNT_FIELD_STYLE = {
display: 'flex',
alignItems: 'center',
gap: 8,
minHeight: 42,
width: '100%',
padding: '0 12px',
background: 'var(--card-bg-2)',
border: '1px solid var(--border)',
borderRadius: 6,
boxSizing: 'border-box',
};
const FINANCE_MODAL_AMOUNT_PREFIX_STYLE = {
flexShrink: 0,
fontSize: 16,
fontWeight: 500,
lineHeight: 1,
color: 'var(--text-secondary)',
};
const FINANCE_MODAL_AMOUNT_INPUT_STYLE = {
flex: 1,
minWidth: 0,
margin: 0,
padding: 0,
border: 'none',
outline: 'none',
background: 'transparent',
boxShadow: 'none',
fontFamily: 'inherit',
fontSize: 22,
fontWeight: 500,
lineHeight: 1.1,
textAlign: 'right',
color: 'var(--text-primary)',
fontVariantNumeric: 'tabular-nums',
appearance: 'textfield',
};
const FINANCE_MODAL_TOTAL_VALUE_STYLE = {
fontSize: 24,
fontWeight: 500,
lineHeight: 1.1,
color: 'var(--accent)',
fontVariantNumeric: 'tabular-nums',
};
function parseSubInvoiceItemVersionNumber(item) {
if (Number.isFinite(Number(item?.version_number))) return Number(item.version_number);
const match = String(item?.description || '').match(/\bR(\d{2})\b/i);
return match ? Number(match[1]) : 0;
}
function subInvoiceItemVersionLabel(item) {
return `R${String(parseSubInvoiceItemVersionNumber(item)).padStart(2, '0')}`;
}
function subInvoiceItemWorkLabel(item) {
return parseSubInvoiceItemVersionNumber(item) > 0 ? 'Revision' : 'New';
}
function cleanSubInvoiceItemDescription(item) {
return String(item?.description || '—').replace(/\s*[-]\s*R\d{2}\b/i, '').trim() || '—';
}
function asArray(value) {
if (Array.isArray(value)) return value;
if (value == null) return [];
return typeof value === 'object' ? [value] : [];
}
async function fetchSubInvoiceTaskTypeMap(taskIds) {
const uniqueTaskIds = [...new Set((taskIds || []).filter(Boolean))];
if (uniqueTaskIds.length === 0) return {};
const { data, error } = await supabase
.from('tasks')
.select('id, title, submissions(service_type, type)')
.in('id', uniqueTaskIds);
if (error) throw error;
const next = {};
for (const task of (data || [])) {
const submissions = asArray(task.submissions);
const initial = submissions.find((submission) => submission?.type === 'initial' && submission?.service_type);
const fallback = submissions.find((submission) => submission?.service_type);
next[task.id] = initial?.service_type || fallback?.service_type || task.title || 'Other';
}
return next;
}
function CurrencyInput({ value, onChange, placeholder = '0.00', required = false, min = '0', step = '0.01' }) {
return (
<div style={FINANCE_MODAL_AMOUNT_FIELD_STYLE}>
<span style={FINANCE_MODAL_AMOUNT_PREFIX_STYLE}>$</span>
<input
type="number"
min={min}
step={step}
required={required}
placeholder={placeholder}
value={value}
onChange={onChange}
style={FINANCE_MODAL_AMOUNT_INPUT_STYLE}
/>
</div>
);
}
const INV_TODAY = new Date().toISOString().split('T')[0]; const INV_TODAY = new Date().toISOString().split('T')[0];
const INV_NET30 = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; const INV_NET30 = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const invNewItem = (description = '', unit_price = '', quantity = 1, task_id = null, submission_id = null) => const invNewItem = (description = '', unit_price = '', quantity = 1, task_id = null, submission_id = null) =>
({ id: crypto.randomUUID(), description, unit_price, quantity, task_id, submission_id }); ({ id: crypto.randomUUID(), description, unit_price, quantity, task_id, submission_id });
const invBuildNewItemDescription = (task) => {
const projectName = task.project?.name || 'No Project';
const taskTitle = task.title || task.service_type || 'Untitled';
return `${projectName}${taskTitle}`;
};
const invBuildRevisionItemDescription = (revision) => {
const projectName = revision.task?.project?.name || 'No Project';
const taskTitle = revision.task?.title || revision.service_type || 'Revision';
const versionLabel = 'R' + String(revision.version_number || 0).padStart(2, '0');
return `${projectName}${taskTitle} • Revision ${versionLabel}`;
};
const blankExpense = () => ({ const blankExpense = () => ({
date: new Date().toISOString().slice(0, 10), date: new Date().toISOString().slice(0, 10),
description: '', description: '',
@@ -78,6 +226,7 @@ function FinancesChartTooltip({ active, payload, label, year }) {
} }
export default function Invoices() { export default function Invoices() {
const { currentUser } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const cached = readPageCache('team_invoices'); const cached = readPageCache('team_invoices');
@@ -126,6 +275,9 @@ export default function Invoices() {
const [subInvoicesLoading, setSubInvoicesLoading] = useState(true); const [subInvoicesLoading, setSubInvoicesLoading] = useState(true);
const [subInvoicesError, setSubInvoicesError] = useState(''); const [subInvoicesError, setSubInvoicesError] = useState('');
const [markingPaid, setMarkingPaid] = useState(''); const [markingPaid, setMarkingPaid] = useState('');
const [viewingSubInvoice, setViewingSubInvoice] = useState(null);
const [viewingSubInvoiceTaskTypeMap, setViewingSubInvoiceTaskTypeMap] = useState({});
const [viewingInvoice, setViewingInvoice] = useState(null);
const [expandedSubInvoiceId, setExpandedSubInvoiceId] = useState(null); const [expandedSubInvoiceId, setExpandedSubInvoiceId] = useState(null);
// New Invoice form // New Invoice form
@@ -143,11 +295,30 @@ export default function Invoices() {
const [invSaving, setInvSaving] = useState(false); const [invSaving, setInvSaving] = useState(false);
const [invLoadingTasks, setInvLoadingTasks] = useState(false); const [invLoadingTasks, setInvLoadingTasks] = useState(false);
const invDragItem = useRef(null); const invDragItem = useRef(null);
const pageLoading = loading || expensesLoading || subcontractorLoading || subInvoicesLoading;
useEffect(() => { useEffect(() => {
supabase.from('companies').select('id, name, contact_email').order('name').then(({ data }) => setInvCompanies(data || [])); supabase.from('companies').select('id, name, contact_email').order('name').then(({ data }) => setInvCompanies(data || []));
}, []); }, []);
useEffect(() => {
let cancelled = false;
async function loadSubInvoiceTaskTypes() {
if (!viewingSubInvoice?.items?.length) {
setViewingSubInvoiceTaskTypeMap({});
return;
}
try {
const map = await fetchSubInvoiceTaskTypeMap(asArray(viewingSubInvoice.items).map((item) => item.task_id));
if (!cancelled) setViewingSubInvoiceTaskTypeMap(map);
} catch {
if (!cancelled) setViewingSubInvoiceTaskTypeMap({});
}
}
loadSubInvoiceTaskTypes();
return () => { cancelled = true; };
}, [viewingSubInvoice]);
useEffect(() => { useEffect(() => {
if (!invCompanyId) { setInvUnbilledTasks([]); setInvUnbilledRevisions([]); setInvPriceList([]); setInvItems([invNewItem()]); setInvBillTo(''); setInvEmail(''); setInvRecipients([]); return; } if (!invCompanyId) { setInvUnbilledTasks([]); setInvUnbilledRevisions([]); setInvPriceList([]); setInvItems([invNewItem()]); setInvBillTo(''); setInvEmail(''); setInvRecipients([]); return; }
const co = invCompanies.find(c => c.id === invCompanyId); const co = invCompanies.find(c => c.id === invCompanyId);
@@ -163,12 +334,32 @@ export default function Invoices() {
setInvEmail((users || [])[0]?.email || co?.contact_email || ''); setInvEmail((users || [])[0]?.email || co?.contact_email || '');
if (projects?.length > 0) { if (projects?.length > 0) {
const pids = projects.map(p => p.id); const pids = projects.map(p => p.id);
const { data: tasks } = await supabase.from('tasks').select('*, project:projects(name), submissions(service_type, type, version_number)').in('project_id', pids).eq('invoiced', false).eq('status', 'client_approved'); const { data: companyTasks } = await supabase
const tids = (tasks || []).map(t => t.id); .from('tasks')
const { data: revisions } = tids.length > 0 ? await supabase.from('submissions').select('*, task:tasks(id, title, project:projects(name), submissions(service_type, type))').eq('type', 'revision').or('revision_type.eq.client_revision,revision_type.is.null').eq('invoiced', false).in('task_id', tids) : { data: [] }; .select('*, project:projects(name), submissions(service_type, type, version_number)')
setInvUnbilledTasks((tasks || []).map(t => { const ini = (t.submissions || []).find(s => s.type === 'initial') || t.submissions?.[0]; return { ...t, service_type: ini?.service_type || t.title }; })); .in('project_id', pids)
.in('status', ['not_started', 'in_progress', 'on_hold', 'client_review', 'client_approved', 'invoiced', 'paid']);
const revisionTaskIds = (companyTasks || []).map(t => t.id).filter(Boolean);
const { data: revisions } = revisionTaskIds.length > 0
? await supabase
.from('submissions')
.select('*, task:tasks(id, title, status, current_version, project:projects(name), submissions(service_type, type))')
.eq('type', 'revision')
.eq('invoiced', false)
.in('task_id', revisionTaskIds)
.order('submitted_at', { ascending: false })
: { data: [] };
const uninvoicedTasks = (companyTasks || []).filter(isInitialVersionEligible);
setInvUnbilledTasks(uninvoicedTasks.map(t => {
const ini = (t.submissions || []).find(s => s.type === 'initial') || t.submissions?.[0];
return { ...t, service_type: ini?.service_type || t.title };
}));
const revMap = new Map(); const revMap = new Map();
for (const r of (revisions || [])) { const k = `${r.task_id}:${r.version_number}`; if (!revMap.has(k)) revMap.set(k, r); } for (const r of (revisions || []).filter(r => !isReviewShadowDescription(r.description))) {
if (!isCompletedVersionEligible(r.task, r.version_number)) continue;
const k = `${r.task_id}:${r.version_number}`;
if (!revMap.has(k)) revMap.set(k, r);
}
setInvUnbilledRevisions([...revMap.values()]); setInvUnbilledRevisions([...revMap.values()]);
} else { setInvUnbilledTasks([]); setInvUnbilledRevisions([]); } } else { setInvUnbilledTasks([]); setInvUnbilledRevisions([]); }
setInvLoadingTasks(false); setInvLoadingTasks(false);
@@ -177,18 +368,17 @@ export default function Invoices() {
const invAddTask = (task) => { const invAddTask = (task) => {
const price = invPriceList.find(p => p.service_type === task.service_type && p.price_type === 'new'); const price = invPriceList.find(p => p.service_type === task.service_type && p.price_type === 'new');
const desc = `${task.project?.name || 'No Project'}${task.title || task.service_type || 'Untitled'}`; const desc = invBuildNewItemDescription(task);
setInvItems(prev => prev.length === 1 && !prev[0].description && !prev[0].unit_price ? [invNewItem(desc, price?.price || '', 1, task.id)] : [...prev, invNewItem(desc, price?.price || '', 1, task.id)]); setInvItems(prev => prev.length === 1 && !prev[0].description && !prev[0].unit_price ? [invNewItem(desc, price?.price || '', 1, task.id)] : [...prev, invNewItem(desc, price?.price || '', 1, task.id)]);
}; };
const invAddRevision = (rev) => { const invAddRevision = (rev) => {
const ini = (rev.task?.submissions || []).find(s => s.type === 'initial') || rev.task?.submissions?.[0]; const ini = (rev.task?.submissions || []).find(s => s.type === 'initial') || rev.task?.submissions?.[0];
const svcType = ini?.service_type || rev.service_type || rev.task?.title || 'Revision'; const svcType = ini?.service_type || rev.service_type || rev.task?.title || 'Revision';
const price = invPriceList.find(p => p.service_type === svcType && p.price_type === 'revision'); const price = invPriceList.find(p => p.service_type === svcType && p.price_type === 'revision');
const version = Number(rev.version_number || 0); const revisionChargeQty = getRevisionChargeQuantity(rev?.version_number);
const qty = version >= 2 ? 1 : 1; const qty = revisionChargeQty > 0 ? revisionChargeQty : 1;
const unitPrice = version >= 2 ? (price?.price || '') : 0; const unitPrice = revisionChargeQty > 0 ? (price?.price || '') : 0;
const vLabel = 'R' + String(version).padStart(2, '0'); const desc = invBuildRevisionItemDescription(rev);
const desc = `${rev.task?.project?.name || 'No Project'}${rev.task?.title || 'Revision'} • Revision ${vLabel}`;
setInvItems(prev => prev.length === 1 && !prev[0].description && !prev[0].unit_price ? [invNewItem(desc, unitPrice, qty, rev.task_id, rev.id)] : [...prev, invNewItem(desc, unitPrice, qty, rev.task_id, rev.id)]); setInvItems(prev => prev.length === 1 && !prev[0].description && !prev[0].unit_price ? [invNewItem(desc, unitPrice, qty, rev.task_id, rev.id)] : [...prev, invNewItem(desc, unitPrice, qty, rev.task_id, rev.id)]);
}; };
const invUpdateItem = (id, field, val) => setInvItems(prev => prev.map(it => it.id === id ? { ...it, [field]: val } : it)); const invUpdateItem = (id, field, val) => setInvItems(prev => prev.map(it => it.id === id ? { ...it, [field]: val } : it));
@@ -226,14 +416,18 @@ export default function Invoices() {
const payUrl = `https://portal.fourgebranding.com/pay/${encodeURIComponent(invoiceNumber)}`; const payUrl = `https://portal.fourgebranding.com/pay/${encodeURIComponent(invoiceNumber)}`;
const pdfItems = validItems.map(it => ({ description: it.description, quantity: Number(it.quantity) || 1, unit_price: Number(it.unit_price) || 0 })); const pdfItems = validItems.map(it => ({ description: it.description, quantity: Number(it.quantity) || 1, unit_price: Number(it.unit_price) || 0 }));
let attachments = []; let attachments = [];
try { const pdf = await withTimeout(generateInvoicePDF({ ...invoice, status: 'sent' }, invSelectedCompany, pdfItems, { save: false }), 8000, 'PDF'); attachments = [await withTimeout(blobToEmailAttachment(pdf, `${invoiceNumber}.pdf`), 5000, 'Attachment')]; } catch {} try { const pdf = await withTimeout(generateInvoicePDF({ ...invoice, status: 'sent' }, invSelectedCompany, pdfItems, { save: false }), 8000, 'PDF'); attachments = [await withTimeout(blobToEmailAttachment(pdf, `${invoiceNumber}.pdf`), 5000, 'Attachment')]; } catch { /* PDF attach failed — send email without attachment */ }
await withTimeout(sendEmail('invoice_sent', invEmail.trim(), { invoiceNumber, billTo: invBillTo || invSelectedCompany?.name, total: `$${invTotal.toFixed(2)}`, dueDate, payUrl, notes: invNotes || '' }, attachments), 12000, 'Email'); await withTimeout(sendEmail('invoice_sent', invEmail.trim(), { invoiceNumber, billTo: invBillTo || invSelectedCompany?.name, total: `$${invTotal.toFixed(2)}`, dueDate, payUrl, notes: invNotes || '' }, attachments), 12000, 'Email');
await supabase.from('invoices').update({ status: 'sent' }).eq('id', invoice.id); await supabase.from('invoices').update({ status: 'sent' }).eq('id', invoice.id);
} catch (e) { alert(`Invoice saved as draft — email failed: ${e.message}`); } } catch (e) { alert(`Invoice saved as draft — email failed: ${e.message}`); }
} }
setInvoices(prev => [{ ...invoice, company: { name: invSelectedCompany?.name || '' } }, ...prev]); const createdInvoice = {
...invoice,
company: invSelectedCompany ? { id: invSelectedCompany.id, name: invSelectedCompany.name } : null,
};
setInvoices(prev => [createdInvoice, ...prev]);
invClose(); invClose();
navigate(`/invoices/${invoice.id}`); setViewingInvoice(createdInvoice);
} catch (e) { alert(`Failed to save invoice: ${e.message || 'Unknown error'}`); } } catch (e) { alert(`Failed to save invoice: ${e.message || 'Unknown error'}`); }
setInvSaving(false); setInvSaving(false);
}; };
@@ -409,26 +603,7 @@ export default function Invoices() {
}; };
const handleSendSubcontractorPO = async (po) => { const handleSendSubcontractorPO = async (po) => {
const sentPO = await updateSubcontractorPO(po, { status: 'sent', sent_at: new Date().toISOString() }, 'Failed to send PO'); await updateSubcontractorPO(po, { status: 'sent', sent_at: new Date().toISOString() }, 'Failed to send PO');
if (!sentPO?.profile?.email) return;
try {
await sendEmail('subcontractor_po_sent', sentPO.profile.email, {
poNumber: sentPO.po_number || 'Purchase Order',
subcontractorName: sentPO.profile?.name || 'there',
projectName: sentPO.project?.name || 'Subcontractor Work',
companyName: sentPO.project?.company?.name || 'Fourge Branding',
amount: `$${Number(sentPO.amount).toFixed(2)}`,
dueDate: sentPO.due_date ? new Date(sentPO.due_date).toLocaleDateString() : 'Not set',
terms: sentPO.terms || 'Net 15',
scope: sentPO.items?.length
? sentPO.items.map(item => `${item.description}$${Number(item.amount).toFixed(2)}`).join('\n')
: sentPO.description,
portalUrl: 'https://portal.fourgebranding.com/my-purchase-orders',
});
} catch (emailError) {
console.error('Failed to email subcontractor PO:', emailError);
alert(`PO was marked sent, but the email failed: ${emailError.message || 'unknown error'}`);
}
}; };
const handleReadyToPaySubcontractorPO = (po) => { const handleReadyToPaySubcontractorPO = (po) => {
@@ -490,6 +665,56 @@ export default function Invoices() {
setMarkingPaid(''); setMarkingPaid('');
}; };
const handleDownloadSubInvoiceReceipt = async (invoice) => {
const items = invoice.items || [];
const total = items.reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
try {
await generateSubcontractorPOPDF({
po_number: invoice.invoice_number,
status: 'paid',
profile: invoice.profile,
project: { name: 'Subcontractor Invoice', company: { name: 'Fourge Branding' } },
date: invoice.created_at?.split('T')[0],
due_date: (invoice.paid_at || new Date().toISOString()).split('T')[0],
amount: total,
description: 'Payment for completed subcontractor work.',
notes: invoice.notes,
items: items.map((item, idx) => ({
description: item.description,
amount: Number(item.unit_price) * Number(item.quantity || 1),
sort_order: item.sort_order ?? idx,
})),
});
} catch (err) {
alert(`Failed to download receipt: ${err.message || 'unknown error'}`);
}
};
const handleDownloadSubInvoice = async (invoice) => {
const items = invoice.items || [];
const total = items.reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
try {
await generateSubcontractorPOPDF({
po_number: invoice.invoice_number,
status: invoice.status || 'draft',
profile: invoice.profile,
project: { name: 'Subcontractor Invoice', company: { name: 'Fourge Branding' } },
date: invoice.created_at?.split('T')[0],
due_date: (invoice.paid_at || invoice.submitted_at || invoice.created_at || new Date().toISOString()).split('T')[0],
amount: total,
description: invoice.status === 'paid' ? 'Payment for completed subcontractor work.' : 'Subcontractor invoice for completed work.',
notes: invoice.notes,
items: items.map((item, idx) => ({
description: item.description,
amount: Number(item.unit_price || 0) * Number(item.quantity || 1),
sort_order: item.sort_order ?? idx,
})),
});
} catch (err) {
alert(`Failed to download invoice: ${err.message || 'unknown error'}`);
}
};
const handleReopenSubcontractorPO = async (po) => { const handleReopenSubcontractorPO = async (po) => {
updateSubcontractorPO(po, { status: 'draft', paid_at: null, cancelled_at: null }, 'Failed to reopen PO'); updateSubcontractorPO(po, { status: 'draft', paid_at: null, cancelled_at: null }, 'Failed to reopen PO');
}; };
@@ -622,7 +847,7 @@ export default function Invoices() {
return ( return (
<Layout> <Layout loading={pageLoading}>
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}> <div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{/* Top row: 60% chart + 40% stat cards */} {/* Top row: 60% chart + 40% stat cards */}
{(() => { {(() => {
@@ -653,7 +878,7 @@ export default function Invoices() {
<div style={{ ...CARD }}> <div style={{ ...CARD }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Revenue Overview</span> <span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Revenue Overview</span>
<select className="filter-select" value={chartYear} onChange={e => setChartYear(Number(e.target.value))} style={{ width: 90, borderRadius: 8, fontSize: 11, fontWeight: 500 }}> <select className="filter-select" value={chartYear} onChange={e => setExportYear(Number(e.target.value))} style={{ width: 90, borderRadius: 8, fontSize: 11, fontWeight: 500 }}>
{(paidYears.length > 0 ? paidYears : [new Date().getFullYear()]).map(y => <option key={y} value={y}>{y}</option>)} {(paidYears.length > 0 ? paidYears : [new Date().getFullYear()]).map(y => <option key={y} value={y}>{y}</option>)}
</select> </select>
</div> </div>
@@ -694,7 +919,6 @@ export default function Invoices() {
{ id: 'expenses', label: 'Expenses' }, { id: 'expenses', label: 'Expenses' },
{ id: 'subcontractors', label: 'Subcontractors' }, { id: 'subcontractors', label: 'Subcontractors' },
{ id: 'invoices', label: 'Invoices' }, { id: 'invoices', label: 'Invoices' },
{ id: 'legacy', label: 'Legacy' },
].map(t => ( ].map(t => (
<button <button
key={t.id} key={t.id}
@@ -1075,7 +1299,7 @@ export default function Invoices() {
return ( return (
<tr key={inv.id}> <tr key={inv.id}>
<td style={{ ...TD }}> <td style={{ ...TD }}>
<button type="button" className="table-link" onClick={() => navigate(`/sub-invoices/${inv.id}`)}> <button type="button" className="table-link" onClick={() => setViewingSubInvoice(inv)}>
{inv.invoice_number || '—'} {inv.invoice_number || '—'}
</button> </button>
</td> </td>
@@ -1195,7 +1419,7 @@ export default function Invoices() {
<tbody>{sorted.map(inv => ( <tbody>{sorted.map(inv => (
<tr key={inv.id}> <tr key={inv.id}>
<td style={{ ...TD }}> <td style={{ ...TD }}>
<button type="button" className="dashboard-inline-link" onClick={() => navigate(`/invoices/${inv.id}`)}>{inv.invoice_number}</button> <button type="button" className="dashboard-inline-link" onClick={() => setViewingInvoice(inv)}>{inv.invoice_number}</button>
</td> </td>
<td style={{ ...TD }}> <td style={{ ...TD }}>
<button type="button" className="dashboard-inline-link" onClick={() => inv.company?.id && navigate(`/company/${inv.company.id}`)}>{inv.company?.name || inv.bill_to || '—'}</button> <button type="button" className="dashboard-inline-link" onClick={() => inv.company?.id && navigate(`/company/${inv.company.id}`)}>{inv.company?.name || inv.bill_to || '—'}</button>
@@ -1252,24 +1476,22 @@ export default function Invoices() {
})()} })()}
{financeTab === 'legacy' && <div> {financeTab === 'legacy' && <div>
<div style={{ display: 'flex', alignItems: 'center', gap: 0, marginBottom: 16 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10, minHeight: 'var(--btn-height)' }}>
{[ {[
{ id: 'invoices', label: 'INVOICES' }, { id: 'invoices', label: 'INVOICES' },
{ id: 'expenses', label: 'EXPENSES' }, { id: 'expenses', label: 'EXPENSES' },
{ id: 'sub-invoices', label: 'SUBCONTRACTOR INVOICES' }, { id: 'sub-invoices', label: 'SUBCONTRACTOR INVOICES' },
].map((t, i, arr) => ( ].map((t, i, arr) => (
<span key={t.id} style={{ display: 'flex', alignItems: 'center' }}>
<button <button
key={t.id}
type="button" type="button"
onClick={() => setActiveTab(t.id)} onClick={() => setActiveTab(t.id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontSize: 13, letterSpacing: 0.5, fontWeight: activeTab === t.id ? 600 : 400, color: activeTab === t.id ? 'var(--text-primary)' : 'var(--text-muted)', fontFamily: 'inherit' }} className={`section-tab-btn${activeTab === t.id ? ' is-active' : ''}`}
>{t.label}</button> >{t.label}</button>
{i < arr.length - 1 && <span style={{ margin: '0 10px', color: 'var(--border)', userSelect: 'none' }}>|</span>}
</span>
))} ))}
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 10, minHeight: 'var(--btn-height)', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{companyNames.length > 0 && activeTab === 'invoices' && ( {companyNames.length > 0 && activeTab === 'invoices' && (
<select className="filter-select" style={{ width: 120 }} value={filterCompany} onChange={e => setFilterCompany(e.target.value)}> <select className="filter-select" style={{ width: 120 }} value={filterCompany} onChange={e => setFilterCompany(e.target.value)}>
@@ -1310,10 +1532,10 @@ export default function Invoices() {
{loading ? ( {loading ? (
<p style={{ color: 'var(--text-muted)' }}>Loading...</p> <p style={{ color: 'var(--text-muted)' }}>Loading...</p>
) : filtered.length === 0 ? ( ) : filtered.length === 0 ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>No invoices.</p> <div className="card card-empty-center">No invoices</div>
) : ( ) : (
<div style={{ background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}> <div className="table-wrapper">
<table> <table className="table-sticky-head">
<thead> <thead>
<tr> <tr>
<SortTh col="invoice_number" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle}>Invoice #</SortTh> <SortTh col="invoice_number" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle}>Invoice #</SortTh>
@@ -1330,7 +1552,7 @@ export default function Invoices() {
if (key === 'invoice_date' || key === 'due_date') return new Date(inv[key]).getTime(); if (key === 'invoice_date' || key === 'due_date') return new Date(inv[key]).getTime();
return inv[key] || inv.company?.name || ''; return inv[key] || inv.company?.name || '';
}).map(inv => ( }).map(inv => (
<tr key={inv.id} onClick={() => navigate(`/invoices/${inv.id}`)} style={{ cursor: 'pointer' }}> <tr key={inv.id} onClick={() => setViewingInvoice(inv)} style={{ cursor: 'pointer' }}>
<td style={{ fontWeight: 400 }}>{inv.invoice_number}</td> <td style={{ fontWeight: 400 }}>{inv.invoice_number}</td>
<td style={{ fontWeight: 400 }}>{inv.bill_to || inv.company?.name}</td> <td style={{ fontWeight: 400 }}>{inv.bill_to || inv.company?.name}</td>
<td style={{ color: 'var(--text-muted)' }}>{new Date(inv.invoice_date).toLocaleDateString()}</td> <td style={{ color: 'var(--text-muted)' }}>{new Date(inv.invoice_date).toLocaleDateString()}</td>
@@ -1359,17 +1581,17 @@ export default function Invoices() {
{activeTab === 'expenses' && ( {activeTab === 'expenses' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<div> <div>
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 10 }}>
{expensesLoading ? ( {expensesLoading ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Loading...</p> <p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Loading...</p>
) : expensesError ? ( ) : expensesError ? (
<p style={{ color: 'var(--danger)', fontSize: 13 }}>{expensesError}</p> <p style={{ color: 'var(--danger)', fontSize: 13 }}>{expensesError}</p>
) : filteredExpenses.length === 0 ? ( ) : filteredExpenses.length === 0 ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>No expenses yet.</p> <div className="card card-empty-center">No expenses</div>
) : ( ) : (
<div style={{ background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}> <div className="table-wrapper">
<table> <table className="table-sticky-head">
<thead> <thead>
<tr> <tr>
<SortTh col="date" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle}>Date</SortTh> <SortTh col="date" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle}>Date</SortTh>
@@ -1431,16 +1653,16 @@ export default function Invoices() {
{activeTab === 'sub-invoices' && ( {activeTab === 'sub-invoices' && (
<div> <div>
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 10 }}>
{subInvoicesLoading ? ( {subInvoicesLoading ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Loading...</p> <p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Loading...</p>
) : subInvoicesError ? ( ) : subInvoicesError ? (
<p style={{ color: 'var(--danger)', fontSize: 13 }}>{subInvoicesError}</p> <p style={{ color: 'var(--danger)', fontSize: 13 }}>{subInvoicesError}</p>
) : subInvoices.length === 0 ? ( ) : subInvoices.length === 0 ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>No sub invoices yet.</p> <div className="card card-empty-center">No sub invoices</div>
) : ( ) : (
<div style={{ background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}> <div className="table-wrapper">
<table> <table className="table-sticky-head">
<thead> <thead>
<tr> <tr>
<SortTh col="invoice_number" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle}>Invoice #</SortTh> <SortTh col="invoice_number" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle}>Invoice #</SortTh>
@@ -1461,7 +1683,7 @@ export default function Invoices() {
const total = (inv.items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0); const total = (inv.items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
const subInvoiceStatusColor = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' }; const subInvoiceStatusColor = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
return ( return (
<tr key={inv.id} style={{ cursor: 'pointer' }} onClick={() => navigate(`/sub-invoices/${inv.id}`)}> <tr key={inv.id} style={{ cursor: 'pointer' }} onClick={() => setViewingSubInvoice(inv)}>
<td style={{ fontWeight: 400 }}>{inv.invoice_number}</td> <td style={{ fontWeight: 400 }}>{inv.invoice_number}</td>
<td> <td>
<div style={{ fontWeight: 400 }}>{inv.profile?.name || 'External'}</div> <div style={{ fontWeight: 400 }}>{inv.profile?.name || 'External'}</div>
@@ -1488,9 +1710,139 @@ export default function Invoices() {
)} )}
</div>}{/* end legacy wrapper */} </div>}{/* end legacy wrapper */}
{viewingSubInvoice && (() => {
const invoice = viewingSubInvoice;
const sortedItems = [...(invoice.items || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
const total = sortedItems.reduce((s, item) => s + Number(item.unit_price || 0) * Number(item.quantity || 1), 0);
const invoiceStatus = invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—';
const subInvoiceStatusColor = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
return (
<div style={popupOverlayStyle} onClick={() => { if (!markingPaid) setViewingSubInvoice(null); }}>
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16, paddingBottom: 18, borderBottom: '1px solid var(--border)' }}>
<div>
<div style={{ fontSize: 18, fontWeight: 500, color: 'var(--text-primary)', letterSpacing: 0.2, lineHeight: 1.1 }}>{invoice.invoice_number || 'Subcontractor Invoice'}</div>
<div style={{ marginTop: 6, fontSize: 13, color: 'var(--text-secondary)' }}>
{invoice.profile?.name || 'External'}{invoice.profile?.email ? ` · ${invoice.profile.email}` : ''}
</div>
</div>
<StatusBadge status={subInvoiceStatusColor[invoice.status] || 'not_started'} label={invoiceStatus} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 18, padding: '18px 0', borderBottom: '1px solid var(--border)' }}>
<div>
<div style={FIELD_LABEL_STYLE}>Submitted</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{invoice.submitted_at ? new Date(invoice.submitted_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div>
</div>
<div>
<div style={FIELD_LABEL_STYLE}>Paid</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{invoice.paid_at ? new Date(invoice.paid_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div>
</div>
<div>
<div style={FIELD_LABEL_STYLE}>Line Items</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{sortedItems.length}</div>
</div>
<div>
<div style={FIELD_LABEL_STYLE}>Total</div>
<div style={{ fontSize: 18, fontWeight: 500, color: 'var(--accent)', lineHeight: 1.1 }}>${total.toFixed(2)}</div>
</div>
</div>
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto', paddingTop: 18 }}>
{sortedItems.length === 0 ? (
<div className="card-empty-center">No line items</div>
) : (
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '8%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '32%' }} />
<col style={{ width: '16%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '12%' }} />
<col style={{ width: '12%' }} />
</colgroup>
<thead>
<tr>
<th style={{ textAlign: 'center' }}>Work</th>
<th style={{ textAlign: 'center' }}>R#</th>
<th style={{ textAlign: 'left' }}>Description</th>
<th style={{ textAlign: 'center' }}>Type</th>
<th style={{ textAlign: 'center' }}>Qty</th>
<th style={{ textAlign: 'center' }}>Unit Price</th>
<th style={{ textAlign: 'center' }}>Amount</th>
</tr>
</thead>
<tbody>
{sortedItems.map(item => (
<tr key={item.id}>
<td style={{ padding: '5px 0', fontSize: 13, color: 'var(--text-primary)', textAlign: 'center' }}>{subInvoiceItemWorkLabel(item)}</td>
<td style={{ padding: '5px 0', fontSize: 13, color: 'var(--text-primary)', textAlign: 'center' }}>{subInvoiceItemVersionLabel(item)}</td>
<td style={{ padding: '5px 0', fontSize: 13, color: 'var(--text-primary)' }}>{cleanSubInvoiceItemDescription(item)}</td>
<td style={{ padding: '5px 0', fontSize: 13, color: 'var(--text-primary)', textAlign: 'center' }}>{viewingSubInvoiceTaskTypeMap[item.task_id] || 'Other'}</td>
<td style={{ padding: '5px 0', textAlign: 'center', fontSize: 13, color: 'var(--text-primary)' }}>{item.quantity || 1}</td>
<td style={{ padding: '5px 0', textAlign: 'center', fontSize: 13, color: 'var(--text-primary)' }}>${Number(item.unit_price || 0).toFixed(2)}</td>
<td style={{ padding: '5px 0', textAlign: 'center', fontSize: 13, color: 'var(--text-primary)' }}>${(Number(item.unit_price || 0) * Number(item.quantity || 1)).toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
)}
{invoice.notes && (
<div style={{ marginTop: 18, padding: '12px 14px', background: 'var(--card-bg-2)', borderRadius: 6, border: '1px solid var(--border)' }}>
<div style={FIELD_LABEL_STYLE}>Notes</div>
<div style={{ fontSize: 13, color: 'var(--text-secondary)', whiteSpace: 'pre-wrap' }}>{invoice.notes}</div>
</div>
)}
</div>
<div className="modal-action-row" style={{ paddingTop: 18, borderTop: '1px solid var(--border)' }}>
<button type="button" className="btn btn-outline" onClick={() => handleDownloadSubInvoice(invoice)}>
Download Invoice
</button>
{invoice.status === 'submitted' && (
<LoadingButton className="btn btn-outline" onClick={async () => {
await handleMarkSubInvoicePaid(invoice);
setViewingSubInvoice(current => current?.id === invoice.id ? { ...current, status: 'paid', paid_at: new Date().toISOString() } : current);
}} loading={markingPaid === invoice.id} loadingText="Processing…">
Mark as Paid
</LoadingButton>
)}
{invoice.status === 'paid' && (
<button type="button" className="btn btn-outline" onClick={() => handleDownloadSubInvoiceReceipt(invoice)}>
Download Receipt
</button>
)}
<button type="button" className="btn btn-outline" onClick={() => setViewingSubInvoice(null)}>
Cancel
</button>
</div>
</div>
</div>
);
})()}
{viewingInvoice && (
<TeamInvoiceDetailPanel
invoiceId={viewingInvoice.id}
initialInvoice={viewingInvoice}
embedded
onClose={() => setViewingInvoice(null)}
onInvoiceUpdated={(updatedInvoice) => {
setViewingInvoice(updatedInvoice);
setInvoices((current) => current.map((invoice) => (invoice.id === updatedInvoice.id ? { ...invoice, ...updatedInvoice } : invoice)));
}}
onInvoiceDeleted={(deletedId) => {
setInvoices((current) => current.filter((invoice) => invoice.id !== deletedId));
setViewingInvoice((current) => (current?.id === deletedId ? null : current));
}}
/>
)}
{viewingExpense && (() => { {viewingExpense && (() => {
const LABEL = { fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 3 }; const LABEL = { ...FIELD_LABEL_STYLE, marginBottom: 3 };
const INPUT = { fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' }; const INPUT = FINANCE_MODAL_INPUT_STYLE;
const isPdf = viewingExpense.receipt_name?.toLowerCase().endsWith('.pdf'); const isPdf = viewingExpense.receipt_name?.toLowerCase().endsWith('.pdf');
const isDirty = expenseDetailEditing && ( const isDirty = expenseDetailEditing && (
newExpense.amount !== String(viewingExpense.amount ?? '') || newExpense.amount !== String(viewingExpense.amount ?? '') ||
@@ -1510,9 +1862,13 @@ export default function Invoices() {
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, flex: '0 0 260px', overflowY: 'auto' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 14, flex: '0 0 260px', overflowY: 'auto' }}>
<div style={LABEL}>Expense Detail</div> <div style={LABEL}>Expense Detail</div>
{expenseDetailEditing ? ( {expenseDetailEditing ? (
<input type="number" step="0.01" min="0" required style={{ ...INPUT, fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#ef4444', fontVariantNumeric: 'tabular-nums', padding: '0 6px' }} value={newExpense.amount} onChange={e => setNewExpense(p => ({ ...p, amount: e.target.value }))} /> <CurrencyInput
value={newExpense.amount}
required
onChange={e => setNewExpense(p => ({ ...p, amount: e.target.value }))}
/>
) : ( ) : (
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#ef4444', fontVariantNumeric: 'tabular-nums' }}> <div style={{ ...FINANCE_MODAL_TOTAL_VALUE_STYLE, color: 'var(--danger)' }}>
${Number(viewingExpense.amount).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${Number(viewingExpense.amount).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div> </div>
)} )}
@@ -1568,7 +1924,7 @@ export default function Invoices() {
)} )}
</div> </div>
{/* Footer: buttons bottom-right */} {/* Footer: buttons bottom-right */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 14, marginTop: 14, flexShrink: 0 }}> <div className="modal-action-row" style={{ paddingTop: 14, marginTop: 14, flexShrink: 0 }}>
{expenseDetailEditing ? ( {expenseDetailEditing ? (
<> <>
<button className="btn btn-outline" disabled={!isDirty || addingExpense} onClick={async () => { await saveExpense(); setExpenseDetailEditing(false); setViewingExpense(null); }}>{addingExpense ? 'Saving…' : 'Save'}</button> <button className="btn btn-outline" disabled={!isDirty || addingExpense} onClick={async () => { await saveExpense(); setExpenseDetailEditing(false); setViewingExpense(null); }}>{addingExpense ? 'Saving…' : 'Save'}</button>
@@ -1590,8 +1946,8 @@ export default function Invoices() {
})()} })()}
{showExpenseForm && (() => { {showExpenseForm && (() => {
const LABEL = { fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 3 }; const LABEL = { ...FIELD_LABEL_STYLE, marginBottom: 3 };
const INPUT = { fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' }; const INPUT = FINANCE_MODAL_INPUT_STYLE;
return ( return (
<div style={popupOverlayStyle} onClick={cancelExpenseEdit}> <div style={popupOverlayStyle} onClick={cancelExpenseEdit}>
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}> <div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
@@ -1601,7 +1957,11 @@ export default function Invoices() {
{/* Left: fields */} {/* Left: fields */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, flex: '0 0 260px', overflowY: 'auto' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 14, flex: '0 0 260px', overflowY: 'auto' }}>
<div style={LABEL}>New Expense</div> <div style={LABEL}>New Expense</div>
<input type="number" step="0.01" min="0" required placeholder="0.00" style={{ ...INPUT, fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#ef4444', fontVariantNumeric: 'tabular-nums', padding: '0 6px' }} value={newExpense.amount} onChange={e => setNewExpense(p => ({ ...p, amount: e.target.value }))} /> <CurrencyInput
value={newExpense.amount}
required
onChange={e => setNewExpense(p => ({ ...p, amount: e.target.value }))}
/>
<div> <div>
<div style={LABEL}>Description</div> <div style={LABEL}>Description</div>
<input type="text" required placeholder="What was this for?" style={INPUT} value={newExpense.description} onChange={e => setNewExpense(p => ({ ...p, description: e.target.value }))} /> <input type="text" required placeholder="What was this for?" style={INPUT} value={newExpense.description} onChange={e => setNewExpense(p => ({ ...p, description: e.target.value }))} />
@@ -1629,7 +1989,7 @@ export default function Invoices() {
</div> </div>
</div> </div>
{/* Footer */} {/* Footer */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 14, marginTop: 14, flexShrink: 0 }}> <div className="modal-action-row" style={{ paddingTop: 14, marginTop: 14, flexShrink: 0 }}>
<button type="submit" className="btn btn-outline" disabled={addingExpense}>{addingExpense ? 'Saving…' : 'Save'}</button> <button type="submit" className="btn btn-outline" disabled={addingExpense}>{addingExpense ? 'Saving…' : 'Save'}</button>
<button type="button" className="btn btn-outline" onClick={cancelExpenseEdit}>Cancel</button> <button type="button" className="btn btn-outline" onClick={cancelExpenseEdit}>Cancel</button>
</div> </div>
@@ -1642,8 +2002,8 @@ export default function Invoices() {
</div>{/* end flex column wrapper */} </div>{/* end flex column wrapper */}
{showInvoiceForm && (() => { {showInvoiceForm && (() => {
const LABEL = { fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 3 }; const LABEL = { ...FIELD_LABEL_STYLE, marginBottom: 3 };
const INPUT = { fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' }; const INPUT = FINANCE_MODAL_INPUT_STYLE;
return ( return (
<div style={popupOverlayStyle} onClick={() => { if (!invSaving) invClose(); }}> <div style={popupOverlayStyle} onClick={() => { if (!invSaving) invClose(); }}>
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}> <div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
@@ -1672,11 +2032,11 @@ export default function Invoices() {
</>} </>}
<div> <div>
<div style={LABEL}>Notes</div> <div style={LABEL}>Notes</div>
<textarea style={{ ...INPUT, minHeight: 72, resize: 'vertical' }} value={invNotes} onChange={e => setInvNotes(e.target.value)} placeholder="Payment terms, notes…" /> <textarea style={FINANCE_MODAL_TEXTAREA_STYLE} value={invNotes} onChange={e => setInvNotes(e.target.value)} placeholder="Payment terms, notes…" />
</div> </div>
<div style={{ marginTop: 'auto', paddingTop: 8 }}> <div style={{ marginTop: 'auto', paddingTop: 8 }}>
<div style={LABEL}>Total</div> <div style={LABEL}>Total</div>
<div style={{ fontSize: 28, fontWeight: 400, color: 'var(--accent)', fontVariantNumeric: 'tabular-nums' }}>${invTotal.toFixed(2)}</div> <div style={FINANCE_MODAL_TOTAL_VALUE_STYLE}>${invTotal.toFixed(2)}</div>
</div> </div>
</div> </div>
@@ -1697,7 +2057,10 @@ export default function Invoices() {
return ( return (
<div key={t.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 10px', background: 'var(--card-bg-2)', borderRadius: 4, border: '1px solid var(--border)', marginBottom: 4 }}> <div key={t.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 10px', background: 'var(--card-bg-2)', borderRadius: 4, border: '1px solid var(--border)', marginBottom: 4 }}>
<div> <div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span className="badge badge-initial">New</span>
<div style={{ fontSize: 12 }}>{t.project?.name} {t.title || t.service_type}</div> <div style={{ fontSize: 12 }}>{t.project?.name} {t.title || t.service_type}</div>
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{price ? `$${Number(price.price).toFixed(2)}` : 'No price'}</div> <div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{price ? `$${Number(price.price).toFixed(2)}` : 'No price'}</div>
</div> </div>
<button type="button" className="btn btn-outline" style={{ fontSize: 11 }} disabled={added} onClick={() => invAddTask(t)}>{added ? 'Added' : '+ Add'}</button> <button type="button" className="btn btn-outline" style={{ fontSize: 11 }} disabled={added} onClick={() => invAddTask(t)}>{added ? 'Added' : '+ Add'}</button>
@@ -1715,9 +2078,20 @@ export default function Invoices() {
</div> </div>
{invUnbilledRevisions.map(r => { {invUnbilledRevisions.map(r => {
const added = invItems.some(i => i.submission_id === r.id); const added = invItems.some(i => i.submission_id === r.id);
const revisionQty = getRevisionChargeQuantity(r?.version_number);
const initial = (r.task?.submissions || []).find(s => s.type === 'initial') || r.task?.submissions?.[0];
const revisionServiceType = initial?.service_type || r.service_type || r.task?.title || 'Revision';
const revisionPrice = invPriceList.find(p => p.service_type === revisionServiceType && p.price_type === 'revision');
const revisionLabel = revisionQty > 0 ? (revisionPrice ? `$${Number(revisionPrice.price).toFixed(2)}` : 'No price') : 'Free';
return ( return (
<div key={r.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 10px', background: 'var(--card-bg-2)', borderRadius: 4, border: '1px solid var(--border)', marginBottom: 4 }}> <div key={r.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 10px', background: 'var(--card-bg-2)', borderRadius: 4, border: '1px solid var(--border)', marginBottom: 4 }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span className="badge badge-client_revision">Revision</span>
<div style={{ fontSize: 12 }}>{r.task?.project?.name} {r.task?.title} R{String(r.version_number || 0).padStart(2, '0')}</div> <div style={{ fontSize: 12 }}>{r.task?.project?.name} {r.task?.title} R{String(r.version_number || 0).padStart(2, '0')}</div>
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{revisionLabel}</div>
</div>
<button type="button" className="btn btn-outline" style={{ fontSize: 11 }} disabled={added} onClick={() => invAddRevision(r)}>{added ? 'Added' : '+ Add'}</button> <button type="button" className="btn btn-outline" style={{ fontSize: 11 }} disabled={added} onClick={() => invAddRevision(r)}>{added ? 'Added' : '+ Add'}</button>
</div> </div>
); );
@@ -1727,16 +2101,19 @@ export default function Invoices() {
{/* Line items */} {/* Line items */}
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<div style={LABEL}>Line Items</div> <div style={LABEL}>Line Items</div>
<div style={{ display: 'grid', gridTemplateColumns: '20px 1fr 60px 100px 90px 28px', gap: 6, marginBottom: 6 }}> <div style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 60px 100px 90px 28px', gap: 6, marginBottom: 6 }}>
{['', 'Description', 'Qty', 'Unit Price', 'Total', ''].map((h, i) => <div key={i} style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.5, color: 'var(--text-muted)', textAlign: i > 2 ? 'right' : 'left' }}>{h}</div>)} {['', 'Type', 'Description', 'Qty', 'Unit Price', 'Total', ''].map((h, i) => <div key={i} style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.5, color: 'var(--text-muted)', textAlign: i > 3 ? 'right' : 'left' }}>{h}</div>)}
</div> </div>
{invItems.map((item, idx) => ( {invItems.map((item, idx) => (
<div key={item.id} onDragOver={e => e.preventDefault()} onDrop={() => invHandleDrop(idx)} style={{ display: 'grid', gridTemplateColumns: '20px 1fr 60px 100px 90px 28px', gap: 6, alignItems: 'center', marginBottom: 4 }}> <div key={item.id} onDragOver={e => e.preventDefault()} onDrop={() => invHandleDrop(idx)} style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 60px 100px 90px 28px', gap: 6, alignItems: 'center', marginBottom: 4 }}>
<div draggable onDragStart={() => { invDragItem.current = idx; }} style={{ cursor: 'grab', color: 'var(--text-muted)', fontSize: 12, textAlign: 'center', userSelect: 'none' }}></div> <div draggable onDragStart={() => { invDragItem.current = idx; }} style={{ cursor: 'grab', color: 'var(--text-muted)', fontSize: 12, textAlign: 'center', userSelect: 'none' }}></div>
<input style={{ ...INPUT, padding: '4px 8px' }} type="text" placeholder="Description…" value={item.description} onChange={e => invUpdateItem(item.id, 'description', e.target.value)} /> <span className={`badge ${item.submission_id ? 'badge-client_revision' : 'badge-initial'}`}>
<input style={{ ...INPUT, padding: '4px 6px', textAlign: 'center' }} type="number" min="1" value={item.quantity} onChange={e => invUpdateItem(item.id, 'quantity', e.target.value)} /> {item.submission_id ? 'Revision' : 'New'}
<input style={{ ...INPUT, padding: '4px 8px', textAlign: 'right' }} type="number" min="0" step="0.01" placeholder="0.00" value={item.unit_price} onChange={e => invUpdateItem(item.id, 'unit_price', e.target.value)} /> </span>
<div style={{ textAlign: 'right', fontSize: 13, color: 'var(--text-primary)', paddingRight: 2 }}>${((Number(item.quantity) || 0) * (Number(item.unit_price) || 0)).toFixed(2)}</div> <input style={FINANCE_MODAL_LINE_ITEM_INPUT_STYLE} type="text" placeholder="Description…" value={item.description} onChange={e => invUpdateItem(item.id, 'description', e.target.value)} />
<input style={{ ...FINANCE_MODAL_NUMBER_INPUT_STYLE, textAlign: 'center' }} type="number" min="1" value={item.quantity} onChange={e => invUpdateItem(item.id, 'quantity', e.target.value)} />
<input style={{ ...FINANCE_MODAL_NUMBER_INPUT_STYLE, textAlign: 'right' }} type="number" min="0" step="0.01" placeholder="0.00" value={item.unit_price} onChange={e => invUpdateItem(item.id, 'unit_price', e.target.value)} />
<div style={{ minHeight: 32, display: 'flex', alignItems: 'center', justifyContent: 'flex-end', textAlign: 'right', fontSize: 13, color: 'var(--text-primary)', paddingRight: 2, fontVariantNumeric: 'tabular-nums' }}>${((Number(item.quantity) || 0) * (Number(item.unit_price) || 0)).toFixed(2)}</div>
<button type="button" onClick={() => invRemoveItem(item.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--danger)', fontSize: 14, padding: 2 }}></button> <button type="button" onClick={() => invRemoveItem(item.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--danger)', fontSize: 14, padding: 2 }}></button>
</div> </div>
))} ))}
@@ -1746,7 +2123,7 @@ export default function Invoices() {
</div> </div>
{/* Footer */} {/* Footer */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 14, marginTop: 14, flexShrink: 0 }}> <div className="modal-action-row" style={{ paddingTop: 14, marginTop: 14, flexShrink: 0 }}>
<button type="button" className="btn btn-outline" disabled={invSaving} onClick={() => invHandleSave('draft')}>{invSaving ? 'Saving…' : 'Save Draft'}</button> <button type="button" className="btn btn-outline" disabled={invSaving} onClick={() => invHandleSave('draft')}>{invSaving ? 'Saving…' : 'Save Draft'}</button>
<LoadingButton className="btn btn-outline" style={{ color: 'var(--accent)', borderColor: 'var(--accent)' }} onClick={() => invHandleSave('sent')} loading={invSaving} loadingText="Sending…">Finalise & Send</LoadingButton> <LoadingButton className="btn btn-outline" style={{ color: 'var(--accent)', borderColor: 'var(--accent)' }} onClick={() => invHandleSave('sent')} loading={invSaving} loadingText="Sending…">Finalise & Send</LoadingButton>
<button type="button" className="btn btn-outline" disabled={invSaving} onClick={invClose}>Cancel</button> <button type="button" className="btn btn-outline" disabled={invSaving} onClick={invClose}>Cancel</button>
+373
View File
@@ -0,0 +1,373 @@
import { useEffect, useMemo, useState } from 'react';
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
import Layout from '../../components/Layout';
import SortTh from '../../components/SortTh';
import StatusBadge from '../../components/StatusBadge';
import LoadingButton from '../../components/LoadingButton';
import { useSortable } from '../../hooks/useSortable';
import { supabase } from '../../lib/supabase';
import { isReviewShadowDescription } from '../../lib/taskVersions';
function fmtVersion(versionNumber) {
return `R${String(Number(versionNumber || 0)).padStart(2, '0')}`;
}
function billingStatusFromInvoices(items = []) {
if (!items.length) return 'not_started';
if (items.some((item) => item.invoice?.status === 'paid')) return 'paid';
return 'invoiced';
}
function billingLabel(status) {
if (status === 'paid') return 'Paid';
if (status === 'invoiced') return 'Invoiced';
return 'Not Invoiced';
}
function versionTypeLabel(versionNumber) {
return Number(versionNumber || 0) === 0 ? 'New' : 'Revision';
}
function versionStateFor(task, versionNumber) {
const currentVersion = Number(task?.current_version || 0);
const version = Number(versionNumber || 0);
if (version < currentVersion) return 'completed';
return task?.status || 'not_started';
}
function rowSort(a, b) {
const companyCmp = (a.company_name || '').localeCompare(b.company_name || '');
if (companyCmp !== 0) return companyCmp;
const projectCmp = (a.project_name || '').localeCompare(b.project_name || '');
if (projectCmp !== 0) return projectCmp;
const taskCmp = (a.task_title || '').localeCompare(b.task_title || '');
if (taskCmp !== 0) return taskCmp;
return Number(a.version_number || 0) - Number(b.version_number || 0);
}
export default function TeamReports() {
const [loading, setLoading] = useState(true);
const [exporting, setExporting] = useState(false);
const [error, setError] = useState('');
const [companies, setCompanies] = useState([]);
const [projects, setProjects] = useState([]);
const [reportRows, setReportRows] = useState([]);
const [companyFilter, setCompanyFilter] = useState('all');
const [projectFilter, setProjectFilter] = useState('all');
const { sortKey, sortDir, toggle, sort } = useSortable('company_name');
useEffect(() => {
let cancelled = false;
async function load() {
setLoading(true);
setError('');
try {
const [
{ data: taskRows, error: tasksError },
{ data: submissionRows, error: submissionsError },
{ data: invoiceItemRows, error: invoiceItemsError },
] = await Promise.all([
supabase
.from('tasks')
.select('id, title, status, current_version, project:projects(id, name, company:companies(id, name))')
.order('title'),
supabase
.from('submissions')
.select('id, task_id, type, version_number, submitted_at, invoiced, description')
.in('type', ['initial', 'revision'])
.order('submitted_at', { ascending: true }),
supabase
.from('invoice_items')
.select('id, task_id, submission_id, invoice:invoices(id, status), submission:submissions(id, task_id, version_number)')
]);
if (tasksError) throw tasksError;
if (submissionsError) throw submissionsError;
if (invoiceItemsError) throw invoiceItemsError;
if (cancelled) return;
const taskMap = new Map((taskRows || []).map((task) => [task.id, task]));
const companiesMap = new Map();
const projectsMap = new Map();
(taskRows || []).forEach((task) => {
const company = task.project?.company;
if (company?.id && !companiesMap.has(company.id)) companiesMap.set(company.id, company);
if (task.project?.id && !projectsMap.has(task.project.id)) projectsMap.set(task.project.id, task.project);
});
const versionMap = new Map();
for (const submission of (submissionRows || [])) {
if (isReviewShadowDescription(submission.description)) continue;
const versionNumber = Number(submission.version_number || 0);
const key = `${submission.task_id}:${versionNumber}`;
if (!versionMap.has(key)) {
versionMap.set(key, {
task_id: submission.task_id,
version_number: versionNumber,
submission_ids: [],
first_submitted_at: submission.submitted_at || null,
});
}
const entry = versionMap.get(key);
entry.submission_ids.push(submission.id);
if (!entry.first_submitted_at || (submission.submitted_at && submission.submitted_at < entry.first_submitted_at)) {
entry.first_submitted_at = submission.submitted_at;
}
}
const invoiceItemGroups = new Map();
for (const item of (invoiceItemRows || [])) {
const versionNumber = item.submission?.version_number ?? (item.submission_id ? null : 0);
const taskId = item.submission?.task_id || item.task_id;
if (!taskId || versionNumber === null) continue;
const key = `${taskId}:${Number(versionNumber || 0)}`;
if (!invoiceItemGroups.has(key)) invoiceItemGroups.set(key, []);
invoiceItemGroups.get(key).push(item);
}
const rows = [];
for (const task of (taskRows || [])) {
const company = task.project?.company;
const project = task.project;
const taskVersions = [];
const initialKey = `${task.id}:0`;
if (versionMap.has(initialKey) || task) {
taskVersions.push(versionMap.get(initialKey) || {
task_id: task.id,
version_number: 0,
submission_ids: [],
first_submitted_at: null,
});
}
for (const versionEntry of versionMap.values()) {
if (versionEntry.task_id !== task.id) continue;
if (Number(versionEntry.version_number || 0) === 0) continue;
taskVersions.push(versionEntry);
}
taskVersions.sort((a, b) => Number(a.version_number || 0) - Number(b.version_number || 0));
for (const versionEntry of taskVersions) {
const key = `${task.id}:${Number(versionEntry.version_number || 0)}`;
const invoiceItems = invoiceItemGroups.get(key) || [];
const billingStatus = billingStatusFromInvoices(invoiceItems);
rows.push({
key,
company_id: company?.id || '',
company_name: company?.name || '—',
project_id: project?.id || '',
project_name: project?.name || '—',
task_id: task.id,
task_title: task.title || 'Untitled',
version_number: Number(versionEntry.version_number || 0),
version_label: fmtVersion(versionEntry.version_number),
version_type: versionTypeLabel(versionEntry.version_number),
version_state: versionStateFor(task, versionEntry.version_number),
billing_status: billingStatus,
billing_label: billingLabel(billingStatus),
first_submitted_at: versionEntry.first_submitted_at,
});
}
}
rows.sort(rowSort);
setCompanies([...companiesMap.values()].sort((a, b) => (a.name || '').localeCompare(b.name || '')));
setProjects([...projectsMap.values()].sort((a, b) => (a.name || '').localeCompare(b.name || '')));
setReportRows(rows);
} catch (err) {
if (!cancelled) setError(err.message || 'Failed to load reports.');
} finally {
if (!cancelled) setLoading(false);
}
}
load();
return () => { cancelled = true; };
}, []);
const filteredProjects = useMemo(() => {
if (companyFilter === 'all') return projects;
return projects.filter((project) => project.company?.id === companyFilter);
}, [projects, companyFilter]);
const visibleRows = useMemo(() => {
return reportRows.filter((row) => {
if (companyFilter !== 'all' && row.company_id !== companyFilter) return false;
if (projectFilter !== 'all' && row.project_id !== projectFilter) return false;
return true;
});
}, [reportRows, companyFilter, projectFilter]);
const sortedRows = useMemo(() => sort(visibleRows, (row, key) => {
if (key === 'company_name') return row.company_name || '';
if (key === 'project_name') return row.project_name || '';
if (key === 'task_title') return row.task_title || '';
if (key === 'version_number') return Number(row.version_number || 0);
if (key === 'version_type') return row.version_type || '';
if (key === 'version_state') return row.version_state || '';
if (key === 'billing_label') return row.billing_label || '';
return '';
}), [visibleRows, sort]);
const stats = useMemo(() => ({
total: visibleRows.length,
notInvoiced: visibleRows.filter((row) => row.billing_status === 'not_started').length,
invoiced: visibleRows.filter((row) => row.billing_status === 'invoiced').length,
paid: visibleRows.filter((row) => row.billing_status === 'paid').length,
}), [visibleRows]);
const handleExportPdf = async () => {
setExporting(true);
try {
const doc = new jsPDF({ orientation: 'landscape', unit: 'pt', format: 'letter', compress: true });
const title = 'Task Billing Report';
const companyName = companyFilter === 'all' ? 'All Companies' : (companies.find((company) => company.id === companyFilter)?.name || 'Selected Company');
const projectName = projectFilter === 'all' ? 'All Projects' : (projects.find((project) => project.id === projectFilter)?.name || 'Selected Project');
const generatedOn = new Date().toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' });
doc.setFontSize(18);
doc.text(title, 40, 40);
doc.setFontSize(10);
doc.text(`Company: ${companyName}`, 40, 60);
doc.text(`Project: ${projectName}`, 240, 60);
doc.text(`Generated: ${generatedOn}`, 440, 60);
autoTable(doc, {
startY: 78,
head: [['Company', 'Project', 'Task', 'Version', 'Type', 'Version Status', 'Billing']],
body: sortedRows.map((row) => [
row.company_name,
row.project_name,
row.task_title,
row.version_label,
row.version_type,
row.version_state === 'client_review' ? 'In Review' : row.version_state === 'client_approved' ? 'Approved' : row.version_state.replace(/_/g, ' '),
row.billing_label,
]),
styles: {
fontSize: 9,
cellPadding: 6,
lineColor: [225, 225, 225],
lineWidth: 0.25,
},
headStyles: {
fillColor: [245, 165, 35],
textColor: [17, 17, 17],
fontStyle: 'bold',
},
alternateRowStyles: {
fillColor: [250, 250, 250],
},
margin: { left: 40, right: 40, bottom: 30 },
didDrawPage: ({ pageNumber }) => {
doc.setFontSize(9);
doc.text(`Page ${pageNumber}`, doc.internal.pageSize.width - 70, doc.internal.pageSize.height - 14);
},
});
doc.save(`task-billing-report-${new Date().toISOString().slice(0, 10)}.pdf`);
} finally {
setExporting(false);
}
};
return (
<Layout loading={loading}>
<div className="page-toolbar-grid" style={{ alignItems: 'center', marginBottom: 24 }}>
<div>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 4 }}>Company</div>
<select value={companyFilter} onChange={(event) => { setCompanyFilter(event.target.value); setProjectFilter('all'); }}>
<option value="all">All Companies</option>
{companies.map((company) => (
<option key={company.id} value={company.id}>{company.name}</option>
))}
</select>
</div>
<div>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 4 }}>Project</div>
<select value={projectFilter} onChange={(event) => setProjectFilter(event.target.value)}>
<option value="all">All Projects</option>
{filteredProjects.map((project) => (
<option key={project.id} value={project.id}>{project.name}</option>
))}
</select>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'end' }}>
<LoadingButton className="btn btn-primary" loading={exporting} loadingText="Generating..." onClick={handleExportPdf} disabled={loading || sortedRows.length === 0}>
Export PDF
</LoadingButton>
</div>
</div>
<div className="dash-stat-grid" style={{ gridTemplateColumns: '1fr 1fr 1fr 1fr', marginBottom: 24 }}>
<div className="stat-card">
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 6 }}>Rows</div>
<div style={{ fontSize: 28, lineHeight: 1.1 }}>{stats.total}</div>
</div>
<div className="stat-card">
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 6 }}>Not Invoiced</div>
<div style={{ fontSize: 28, lineHeight: 1.1 }}>{stats.notInvoiced}</div>
</div>
<div className="stat-card">
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 6 }}>Invoiced</div>
<div style={{ fontSize: 28, lineHeight: 1.1 }}>{stats.invoiced}</div>
</div>
<div className="stat-card">
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 6 }}>Paid</div>
<div style={{ fontSize: 28, lineHeight: 1.1 }}>{stats.paid}</div>
</div>
</div>
<div className="table-wrapper" style={{ minHeight: 0 }}>
{loading ? (
<div style={{ padding: 24, fontSize: 13, color: 'var(--text-muted)', textAlign: 'center' }}>Loading report</div>
) : error ? (
<div style={{ padding: 24, fontSize: 13, color: 'var(--danger)', textAlign: 'center' }}>{error}</div>
) : sortedRows.length === 0 ? (
<div style={{ padding: 24, fontSize: 13, color: 'var(--text-muted)', textAlign: 'center' }}>No report rows for the selected filters.</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '16%' }} />
<col style={{ width: '16%' }} />
<col style={{ width: '24%' }} />
<col style={{ width: '8%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '13%' }} />
<col style={{ width: '13%' }} />
</colgroup>
<thead>
<tr>
<SortTh col="company_name" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Company</SortTh>
<SortTh col="project_name" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Project</SortTh>
<SortTh col="task_title" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Task Name</SortTh>
<SortTh col="version_number" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>R#</SortTh>
<SortTh col="version_type" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Type</SortTh>
<SortTh col="version_state" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Version Status</SortTh>
<SortTh col="billing_label" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Billing</SortTh>
</tr>
</thead>
<tbody>
{sortedRows.map((row) => (
<tr key={row.key}>
<td>{row.company_name}</td>
<td>{row.project_name}</td>
<td>{row.task_title}</td>
<td>{row.version_label}</td>
<td><StatusBadge status={row.version_number === 0 ? 'initial' : 'revision'} label={row.version_type} /></td>
<td><StatusBadge status={row.version_state} /></td>
<td><StatusBadge status={row.billing_status} label={row.billing_label} /></td>
</tr>
))}
</tbody>
</table>
)}
</div>
</Layout>
);
}
+77 -114
View File
@@ -1,14 +1,13 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout'; import Layout from '../../components/Layout';
import SortTh from '../../components/SortTh'; import PageLoader from '../../components/PageLoader';
import StatusBadge from '../../components/StatusBadge'; import LoadingButton from '../../components/LoadingButton';
import { supabase } from '../../lib/supabase'; import { supabase } from '../../lib/supabase';
import { generateSubcontractorPOPDF } from '../../lib/invoice'; import { generateSubcontractorPOPDF } from '../../lib/invoice';
import { blobToEmailAttachment, sendEmail } from '../../lib/email'; import { blobToEmailAttachment, sendEmail } from '../../lib/email';
import { useSortable } from '../../hooks/useSortable'; import { useSortable } from '../../hooks/useSortable';
import SubcontractorInvoiceDetailView from '../../components/SubcontractorInvoiceDetailView';
const statusColor = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
export default function SubInvoiceDetail() { export default function SubInvoiceDetail() {
const { id } = useParams(); const { id } = useParams();
@@ -17,6 +16,7 @@ export default function SubInvoiceDetail() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [generating, setGenerating] = useState(false); const [generating, setGenerating] = useState(false);
const [error, setError] = useState('');
const { sortKey, sortDir, toggle, sort } = useSortable('description'); const { sortKey, sortDir, toggle, sort } = useSortable('description');
useEffect(() => { useEffect(() => {
@@ -25,18 +25,24 @@ export default function SubInvoiceDetail() {
.select('*, profile:profiles!subcontractor_invoices_profile_id_fkey(id, name, email), items:subcontractor_invoice_items(*)') .select('*, profile:profiles!subcontractor_invoices_profile_id_fkey(id, name, email), items:subcontractor_invoice_items(*)')
.eq('id', id) .eq('id', id)
.single() .single()
.then(({ data }) => { .then(({ data, error: err }) => {
setInvoice(data); if (err || !data) setError('Invoice not found.');
setInvoice(data || null);
setLoading(false); setLoading(false);
}); });
}, [id]); }, [id]);
const total = (invoice?.items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0); const total = (invoice?.items || []).reduce((sum, item) => sum + Number(item.unit_price || 0) * Number(item.quantity || 1), 0);
const sortedItems = [...(invoice?.items || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
const tableItems = sort(sortedItems, (item, key) => { const tableItems = sort(sortedItems, (item, key) => {
const typeSort = /\bR(\d{2})\b/i.test(String(item.description || ''))
? Number(String(item.description).match(/\bR(\d{2})\b/i)?.[1] || 0)
: item.task_id ? 0 : 999;
if (key === 'type') return typeSort;
if (key === 'description') return item.description || ''; if (key === 'description') return item.description || '';
if (key === 'quantity') return Number(item.quantity || 0); if (key === 'quantity') return Number(item.quantity || 0);
if (key === 'unit_price') return Number(item.unit_price || 0); if (key === 'unit_price') return Number(item.unit_price || 0);
if (key === 'amount') return Number(item.unit_price || 0) * Number(item.quantity || 1); if (key === 'line_total') return Number(item.unit_price || 0) * Number(item.quantity || 1);
return ''; return '';
}); });
@@ -58,15 +64,18 @@ export default function SubInvoiceDetail() {
}); });
const handleMarkPaid = async () => { const handleMarkPaid = async () => {
if (!invoice) return;
setSaving(true); setSaving(true);
try { try {
const paidAt = new Date().toISOString(); const paidAt = new Date().toISOString();
const { error } = await supabase.from('subcontractor_invoices').update({ status: 'paid', paid_at: paidAt }).eq('id', id); const { error: updateError } = await supabase.from('subcontractor_invoices').update({ status: 'paid', paid_at: paidAt }).eq('id', id);
if (error) throw error; if (updateError) throw updateError;
const updated = { ...invoice, status: 'paid', paid_at: paidAt }; const updated = { ...invoice, status: 'paid', paid_at: paidAt };
setInvoice(updated); setInvoice(updated);
let pdfBlob = null; let pdfBlob = null;
try { pdfBlob = await generateSubcontractorPOPDF(buildPDFArgs(updated), { output: 'blob' }); } catch {} try {
pdfBlob = await generateSubcontractorPOPDF(buildPDFArgs(updated), { output: 'blob' });
} catch { /* PDF generation failed — continue without attachment */ }
if (invoice.profile?.email) { if (invoice.profile?.email) {
try { try {
const attachments = pdfBlob ? [await blobToEmailAttachment(pdfBlob, `${invoice.invoice_number}-receipt.pdf`)] : []; const attachments = pdfBlob ? [await blobToEmailAttachment(pdfBlob, `${invoice.invoice_number}-receipt.pdf`)] : [];
@@ -77,136 +86,90 @@ export default function SubInvoiceDetail() {
paidDate: new Date(paidAt).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }), paidDate: new Date(paidAt).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }),
}, attachments); }, attachments);
} catch (emailErr) { } catch (emailErr) {
alert(`Marked paid, but email failed: ${emailErr.message || 'unknown error'}`); setError(`Marked paid, but email failed: ${emailErr.message || 'unknown error'}`);
} }
} }
} catch (err) { } catch (err) {
alert(`Failed to mark as paid: ${err.message}`); setError(`Failed to mark as paid: ${err.message}`);
} }
setSaving(false); setSaving(false);
}; };
const handleReceipt = async () => { const handleReceipt = async () => {
if (!invoice) return;
setGenerating(true); setGenerating(true);
try { try {
await generateSubcontractorPOPDF(buildPDFArgs(invoice)); await generateSubcontractorPOPDF(buildPDFArgs(invoice));
} catch (err) { } catch (err) {
alert(err.message); setError(err.message);
} }
setGenerating(false); setGenerating(false);
}; };
const handleDelete = async () => { const handleDelete = async () => {
if (!invoice) return;
if (!window.confirm(`Delete invoice ${invoice.invoice_number}? This cannot be undone.`)) return; if (!window.confirm(`Delete invoice ${invoice.invoice_number}? This cannot be undone.`)) return;
setSaving(true); setSaving(true);
const { error } = await supabase.from('subcontractor_invoices').delete().eq('id', id); const { error: deleteError } = await supabase.from('subcontractor_invoices').delete().eq('id', id);
if (error) { alert('Failed to delete: ' + error.message); setSaving(false); return; } if (deleteError) {
navigate('/invoices', { state: { tab: 'sub-invoices' } }); setError(`Failed to delete: ${deleteError.message}`);
setSaving(false);
return;
}
navigate('/finances', { state: { tab: 'sub-invoices' } });
}; };
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>; if (loading) return <Layout><PageLoader /></Layout>;
if (!invoice) return <Layout><p style={{ padding: 24 }}>Invoice not found.</p></Layout>; if (!invoice) return <Layout><p style={{ padding: 24 }}>{error || 'Invoice not found.'}</p></Layout>;
const sortedItems = [...(invoice.items || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)); const headerActions = (
<>
{invoice.status === 'submitted' ? (
<LoadingButton className="btn btn-outline" loading={saving} loadingText="Processing…" disabled={saving} onClick={handleMarkPaid}>
Mark as Paid
</LoadingButton>
) : null}
{invoice.status === 'paid' ? (
<LoadingButton className="btn btn-outline" loading={generating} loadingText="Generating…" disabled={generating} onClick={handleReceipt}>
Download Receipt
</LoadingButton>
) : null}
<button className="btn btn-outline" style={{ color: 'var(--danger)', borderColor: 'var(--danger)' }} onClick={handleDelete} disabled={saving}>
Delete
</button>
</>
);
return ( return (
<Layout> <Layout>
<button className="back-link" onClick={() => navigate('/invoices', { state: { tab: 'sub-invoices' } })}> <SubcontractorInvoiceDetailView
Back to Subcontractor Invoices error={error}
</button> headerActions={headerActions}
leftCardTitle="Submitted By"
<div className="page-header"> leftCardBody={(
<div> <>
<div className="page-title">{invoice.invoice_number}</div> <div style={{ fontSize: 15, fontWeight: 400 }}>{invoice.profile?.name || 'External'}</div>
<div className="page-subtitle"> <div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 4 }}>{invoice.profile?.email || '—'}</div>
{invoice.profile?.name || 'External'} · {invoice.profile?.email || '—'} </>
)}
rightCardTitle="Invoice Details"
rightCardBody={(
<div className="invoice-detail-meta-grid">
<div className="invoice-detail-meta-item"><label>Invoice #</label><p>{invoice.invoice_number}</p></div>
<div className="invoice-detail-meta-item"><label>Status</label><p>{invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—'}</p></div>
<div className="invoice-detail-meta-item"><label>Submitted</label><p>{invoice.submitted_at ? new Date(invoice.submitted_at).toLocaleDateString() : '—'}</p></div>
{invoice.paid_at ? <div className="invoice-detail-meta-item"><label>Paid On</label><p style={{ color: 'var(--success, #16a34a)' }}>{new Date(invoice.paid_at).toLocaleDateString()}</p></div> : null}
<div className="invoice-detail-meta-item"><label>Line Items</label><p>{sortedItems.length}</p></div>
<div className="invoice-detail-meta-item"><label>Total</label><p style={{ fontSize: 18, color: 'var(--accent)' }}>${total.toFixed(2)}</p></div>
</div> </div>
</div> )}
<StatusBadge sortKey={sortKey}
status={statusColor[invoice.status] || 'not_started'} sortDir={sortDir}
label={invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—'} onSort={toggle}
items={tableItems}
notes={invoice.notes}
total={total}
/> />
</div>
<div className="grid-2" style={{ marginBottom: 24 }}>
<div className="card">
<div className="card-title">Invoice Info</div>
<div className="detail-grid" style={{ marginBottom: 0 }}>
<div className="detail-item"><label>Invoice #</label><p style={{ fontWeight: 400 }}>{invoice.invoice_number}</p></div>
<div className="detail-item"><label>Status</label><p style={{ textTransform: 'capitalize' }}>{invoice.status}</p></div>
<div className="detail-item"><label>Submitted</label><p>{invoice.submitted_at ? new Date(invoice.submitted_at).toLocaleDateString() : '—'}</p></div>
{invoice.paid_at && (
<div className="detail-item"><label>Paid On</label><p style={{ color: 'var(--success)', fontWeight: 400 }}>{new Date(invoice.paid_at).toLocaleDateString()}</p></div>
)}
</div>
</div>
<div className="card">
<div className="card-title">Summary</div>
<div className="detail-grid" style={{ marginBottom: 0 }}>
<div className="detail-item"><label>Line Items</label><p>{sortedItems.length}</p></div>
<div className="detail-item"><label>Total</label><p style={{ fontWeight: 400, fontSize: 18 }}>${total.toFixed(2)}</p></div>
</div>
</div>
</div>
<div className="card" style={{ marginBottom: 24 }}>
<div className="card-title">Line Items</div>
{sortedItems.length === 0 ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>No line items.</p>
) : (
<div className="table-wrapper" style={{ border: 'none' }}>
<table>
<thead>
<tr>
<SortTh col="description" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Description</SortTh>
<SortTh col="quantity" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Qty</SortTh>
<SortTh col="unit_price" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Unit Price</SortTh>
<SortTh col="amount" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Amount</SortTh>
</tr>
</thead>
<tbody>
{tableItems.map(item => (
<tr key={item.id}>
<td>{item.description}</td>
<td style={{ textAlign: 'right' }}>{item.quantity}</td>
<td style={{ textAlign: 'right' }}>${Number(item.unit_price).toFixed(2)}</td>
<td style={{ textAlign: 'right', fontWeight: 400 }}>${(Number(item.unit_price) * Number(item.quantity || 1)).toFixed(2)}</td>
</tr>
))}
<tr>
<td colSpan={3} style={{ textAlign: 'right', fontWeight: 400, borderTop: '1px solid var(--border)', paddingTop: 10 }}>Total</td>
<td style={{ textAlign: 'right', fontWeight: 400, fontSize: 16, borderTop: '1px solid var(--border)', paddingTop: 10 }}>${total.toFixed(2)}</td>
</tr>
</tbody>
</table>
</div>
)}
{invoice.notes && (
<div style={{ marginTop: 16, padding: '12px 14px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<div style={{ fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 6 }}>Notes</div>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', whiteSpace: 'pre-wrap', margin: 0 }}>{invoice.notes}</p>
</div>
)}
</div>
<div className="card">
<div className="card-title">Actions</div>
<div className="action-buttons">
{invoice.status === 'submitted' && (
<button className="btn btn-success" onClick={handleMarkPaid} disabled={saving}>
{saving ? 'Processing…' : 'Mark as Paid'}
</button>
)}
{invoice.status === 'paid' && (
<button className="btn btn-outline" onClick={handleReceipt} disabled={generating}>
{generating ? 'Generating…' : 'Download Receipt'}
</button>
)}
<button className="btn-icon btn-icon-danger" title="Delete Invoice" onClick={handleDelete} disabled={saving}>
</button>
</div>
</div>
</Layout> </Layout>
); );
} }
+5 -40
View File
@@ -1,11 +1,11 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import Layout from '../../components/Layout'; import Layout from '../../components/Layout';
import PageLoader from '../../components/PageLoader';
import LoadingButton from '../../components/LoadingButton'; import LoadingButton from '../../components/LoadingButton';
import SortTh from '../../components/SortTh'; import SortTh from '../../components/SortTh';
import StatusBadge from '../../components/StatusBadge'; import StatusBadge from '../../components/StatusBadge';
import { supabase } from '../../lib/supabase'; import { supabase } from '../../lib/supabase';
import { sendEmail } from '../../lib/email';
import { generateSubcontractorPOPDF } from '../../lib/invoice'; import { generateSubcontractorPOPDF } from '../../lib/invoice';
import { useSortable } from '../../hooks/useSortable'; import { useSortable } from '../../hooks/useSortable';
@@ -73,49 +73,14 @@ export default function SubcontractorPODetail() {
return data; return data;
}; };
const sendPOEmail = async (sentPO) => {
if (!sentPO?.profile?.email) {
alert('This subcontractor has no email on file.');
return;
}
await sendEmail('subcontractor_po_sent', sentPO.profile.email, {
poNumber: sentPO.po_number || 'Purchase Order',
subcontractorName: sentPO.profile?.name || 'there',
projectName: sentPO.project?.name || 'Subcontractor Work',
companyName: sentPO.project?.company?.name || 'Fourge Branding',
amount: `$${Number(sentPO.amount).toFixed(2)}`,
dueDate: sentPO.due_date ? new Date(sentPO.due_date).toLocaleDateString() : 'Not set',
terms: sentPO.terms || 'Net 15',
scope: sentPO.items?.length
? sentPO.items.map(item => `${item.description} - $${Number(item.amount).toFixed(2)}`).join('\n')
: sentPO.description,
portalUrl: 'https://portal.fourgebranding.com/my-purchase-orders',
});
};
const handleFinalizeSend = async () => { const handleFinalizeSend = async () => {
if (!window.confirm(`Finalize and send ${po.po_number || 'this PO'} to ${po.profile?.email || 'the subcontractor'}?`)) return; if (!window.confirm(`Finalize and send ${po.po_number || 'this PO'} to ${po.profile?.email || 'the subcontractor'}?`)) return;
const sentPO = await updatePO({ status: 'sent', sent_at: new Date().toISOString() }, 'Failed to finalize PO'); const sentPO = await updatePO({ status: 'sent', sent_at: new Date().toISOString() }, 'Failed to finalize PO');
if (!sentPO) return; if (!sentPO) return;
try {
await sendPOEmail(sentPO);
alert('PO email sent successfully.');
} catch (error) {
alert(`PO was finalized, but the email failed: ${error.message || 'unknown error'}`);
}
}; };
const handleResend = async () => { const handleResend = async () => {
if (!window.confirm(`Resend ${po.po_number || 'this PO'} to ${po.profile?.email || 'the subcontractor'}?`)) return; alert('PO emails are disabled.');
setSaving(true);
try {
await sendPOEmail(po);
alert('PO email sent successfully.');
} catch (error) {
alert(`Failed to send PO: ${error.message || 'unknown error'}`);
} finally {
setSaving(false);
}
}; };
const handleReadyToPay = () => updatePO({ status: 'ready_to_pay' }, 'Failed to mark ready to pay'); const handleReadyToPay = () => updatePO({ status: 'ready_to_pay' }, 'Failed to mark ready to pay');
@@ -136,7 +101,7 @@ export default function SubcontractorPODetail() {
setSaving(false); setSaving(false);
return; return;
} }
navigate('/invoices', { state: { tab: 'subcontractor-po' } }); navigate('/finances', { state: { tab: 'subcontractor-po' } });
}; };
const handleDownload = async () => { const handleDownload = async () => {
@@ -149,7 +114,7 @@ export default function SubcontractorPODetail() {
} }
}; };
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>; if (loading) return <Layout><PageLoader /></Layout>;
if (!po) return <Layout><p>Purchase order not found.</p></Layout>; if (!po) return <Layout><p>Purchase order not found.</p></Layout>;
const sortedItems = (po.items || []).slice().sort((a, b) => Number(a.sort_order || 0) - Number(b.sort_order || 0)); const sortedItems = (po.items || []).slice().sort((a, b) => Number(a.sort_order || 0) - Number(b.sort_order || 0));
@@ -163,7 +128,7 @@ export default function SubcontractorPODetail() {
return ( return (
<Layout> <Layout>
<button className="back-link" onClick={() => navigate('/invoices', { state: { tab: 'subcontractor-po' } })}> Back to Subcontractor PO</button> <button className="back-link" onClick={() => navigate('/finances', { state: { tab: 'subcontractor-po' } })}> Back to Subcontractor PO</button>
<div className="page-header"> <div className="page-header">
<div> <div>
-168
View File
@@ -1,168 +0,0 @@
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
const FBQ_URL = Deno.env.get('FBQ_URL') ?? '';
const FBQ_TOKEN = Deno.env.get('FBQ_TOKEN') ?? '';
const SUPABASE_URL = Deno.env.get('SUPABASE_URL') ?? '';
const SUPABASE_SERVICE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '';
const SOURCE = 'srv';
const fbqH = { Authorization: `Bearer ${FBQ_TOKEN}` };
function safe(v: string) {
return String(v||'').trim().replace(/[\\/:*?"<>|#%{}^~[\]`]+/g,'-').replace(/\s+/g,' ').replace(/^-+|-+$/g,'');
}
// 200 = created new, 409 = already existed
async function mkdir(path: string): Promise<boolean> {
const p = path.endsWith('/') ? path : path + '/';
const r = await fetch(`${FBQ_URL}/api/resources?source=${SOURCE}&path=${encodeURIComponent(p)}&isDir=true`, {
method: 'POST', headers: fbqH,
});
return r.status === 200;
}
async function upload(path: string, data: ArrayBuffer, ct: string) {
await fetch(`${FBQ_URL}/api/resources?source=${SOURCE}&path=${encodeURIComponent(path)}`, {
method: 'POST', headers: { ...fbqH, 'Content-Type': ct }, body: data,
});
}
function mime(name: string) {
const e = name.split('.').pop()?.toLowerCase() ?? '';
const m: Record<string,string> = { pdf:'application/pdf', jpg:'image/jpeg', jpeg:'image/jpeg', png:'image/png', gif:'image/gif', webp:'image/webp', svg:'image/svg+xml', ai:'application/postscript', eps:'application/postscript', zip:'application/zip', mp4:'video/mp4', mov:'video/quicktime' };
return m[e] ?? 'application/octet-stream';
}
async function listDir(path: string, log?: string[]): Promise<{ name: string }[] | null> {
const r = await fetch(`${FBQ_URL}/api/resources?source=${SOURCE}&path=${encodeURIComponent(path)}`, { headers: fbqH });
if (log) log.push(`listDir ${path}${r.status}`);
if (!r.ok) return null;
const data = await r.json().catch(() => null);
const entries = Array.isArray(data?.entries) ? data.entries
: [...(data?.folders ?? []), ...(data?.files ?? [])];
return entries;
}
async function fbDelete(path: string): Promise<boolean> {
const r = await fetch(`${FBQ_URL}/api/resources?source=${SOURCE}&path=${encodeURIComponent(path)}`, { method: 'DELETE', headers: fbqH });
return r.ok;
}
async function fbRename(fromPath: string, toPath: string): Promise<boolean> {
const r = await fetch(`${FBQ_URL}/api/resources?source=${SOURCE}`, {
method: 'PATCH',
headers: { ...fbqH, 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'rename', items: [{ fromSource: SOURCE, fromPath, toSource: SOURCE, toPath }], overwrite: true }),
});
return r.ok;
}
async function migrateOldBook(base: string, log: string[]): Promise<void> {
const src = `${base}/Old Book`;
const dst = `${base}/Old Books`;
const srcItems = await listDir(src, log);
if (srcItems === null) return;
if (srcItems.length === 0) {
// Empty — just delete it
const ok = await fbDelete(src);
log.push(`${ok ? 'migrated' : 'migrate-fail'} (delete empty): ${src}`);
} else {
// Has contents — move each item then delete
for (const item of srcItems) {
await fbRename(`${src}/${item.name}`, `${dst}/${item.name}`);
}
const ok = await fbDelete(src);
log.push(`${ok ? 'migrated' : 'migrate-fail'} (merge+delete): ${src}${dst}`);
}
}
async function chunkAll<T>(items: T[], fn: (item: T) => Promise<void>, size = 8) {
for (let i = 0; i < items.length; i += size) {
await Promise.all(items.slice(i, i + size).map(fn));
}
}
Deno.serve(async () => {
const sb = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY);
const log: string[] = [];
let created = 0, skipped = 0, fileCount = 0, errors = 0;
// fetch all data upfront in parallel
const [usersRes, companiesRes, tasksRes] = await Promise.all([
sb.from('profiles').select('name,role').in('role',['team','external']),
sb.from('companies').select('name'),
sb.from('tasks').select('id,title,projects!project_id(name,companies!company_id(name))'),
]);
log.push(`db: ${usersRes.data?.length} users, ${companiesRes.data?.length} companies, ${tasksRes.data?.length ?? 0} tasks`);
if (tasksRes.error) log.push(`task err: ${tasksRes.error.message}`);
// 1. user folders in parallel
await mkdir('/Team');
await chunkAll(usersRes.data ?? [], async (u) => {
if (!u.name) return;
const isNew = await mkdir(`/Team/${safe(u.name)}`);
if (isNew) { log.push(`+ /Team/${safe(u.name)}`); created++; } else skipped++;
});
// 2. company folders in parallel
await mkdir('/Clients');
await chunkAll(companiesRes.data ?? [], async (c) => {
if (!c.name) return;
const isNew = await mkdir(`/Clients/${safe(c.name)}`);
if (isNew) { log.push(`+ /Clients/${safe(c.name)}`); created++; } else skipped++;
});
// 3. tasks — project structure + task folder in parallel, then files sequentially for new ones
const newTasks: { task: any; base: string; co: string; pr: string }[] = [];
await chunkAll(tasksRes.data ?? [], async (task: any) => {
const coName = task?.projects?.companies?.name;
const prjName = task?.projects?.name;
if (!coName || !prjName || !task.title) return;
const co = safe(coName);
const pr = safe(prjName);
const tk = safe(task.title);
const base = `/Clients/${co}/Projects/${pr}/${tk}`;
// ensure project structure (idempotent)
await Promise.all([
mkdir(`/Clients/${co}/Projects`),
mkdir(`/Clients/${co}/Projects/${pr}`),
mkdir(`/Clients/${co}/Projects/${pr}/00 Project Files`),
]);
const taskIsNew = await mkdir(base);
await Promise.all([mkdir(`${base}/Request Info`), mkdir(`${base}/Old Books`)]);
if (log.filter(l => l.startsWith('listDir')).length < 3) await migrateOldBook(base, log);
if (taskIsNew) { newTasks.push({ task, base, co, pr }); created++; }
else skipped++;
}, 6);
log.push(`new task folders: ${newTasks.length}`);
newTasks.forEach(({ base }) => log.push(`+ ${base}`));
// 4. upload files for newly created task folders (sequential to avoid memory spikes)
for (const { task, base } of newTasks) {
const { data: subs } = await sb.from('submissions').select('id,version_number').eq('task_id', task.id);
for (const sub of subs ?? []) {
const { data: subFiles } = await sb.from('submission_files').select('name,storage_path').eq('submission_id', sub.id);
if (!subFiles?.length) continue;
const rev = `R${String(sub.version_number).padStart(2,'0')}`;
await mkdir(`${base}/Request Info/${rev}`);
for (const f of subFiles) {
try {
const { data: blob, error } = await sb.storage.from('submissions').download(f.storage_path);
if (error || !blob) { errors++; continue; }
await upload(`${base}/Request Info/${rev}/${f.name}`, await blob.arrayBuffer(), mime(f.name));
fileCount++;
} catch { errors++; }
}
}
}
return new Response(JSON.stringify({ created, skipped, files: fileCount, errors, log }, null, 2), {
headers: { 'Content-Type': 'application/json' },
});
});
-288
View File
@@ -1,288 +0,0 @@
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
const FBQ_URL = Deno.env.get('FBQ_URL') ?? '';
const FBQ_TOKEN = Deno.env.get('FBQ_TOKEN') ?? '';
const SUPABASE_URL = Deno.env.get('SUPABASE_URL') ?? '';
const SUPABASE_SERVICE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '';
const SOURCE = 'srv';
function cors(origin: string) {
return {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Authorization, Content-Type, X-Operation, X-Path, X-Files',
};
}
function json(body: unknown, status = 200, origin = '*') {
return new Response(JSON.stringify(body), {
status,
headers: { ...cors(origin), 'Content-Type': 'application/json' },
});
}
function normalizePath(path: string) {
const raw = String(path || '/').trim();
const parts = raw.split('/').filter(Boolean);
return '/' + parts.join('/');
}
function safeSegment(value: string, fallback = '') {
const cleaned = String(value || '')
.trim()
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
.replace(/\s+/g, ' ')
.replace(/^-+|-+$/g, '');
return cleaned || fallback;
}
type ExternalProject = {
id: string;
name: string;
companyName: string;
virtualName: string;
virtualRoot: string;
realRoot: string;
};
type Access =
| { role: 'team'; roots: string[] }
| { role: 'client'; roots: string[] }
| { role: 'external'; roots: string[]; projects: ExternalProject[] };
function pathAllowed(path: string, access: Access): boolean {
if (access.roots.includes('*')) return true;
const p = normalizePath(path).replace(/\/+$/, '') || '/';
if (access.role === 'client' && p === '/Clients' && access.roots.some(r => r.startsWith('/Clients/'))) return true;
if (access.role === 'external' && p === '/') return true;
return access.roots.some(r => p === r || p.startsWith(r.replace(/\/$/, '') + '/'));
}
async function getExternalProjects(sb: ReturnType<typeof createClient>, userId: string): Promise<ExternalProject[]> {
const { data, error } = await sb
.from('project_members')
.select('project:projects(id, name, company:companies(name))')
.eq('profile_id', userId);
if (error) throw error;
const seen = new Map<string, number>();
return (data ?? [])
.map((row: any) => {
const projectId = row?.project?.id;
const projectName = row?.project?.name;
const companyName = row?.project?.company?.name;
if (!projectId || !projectName || !companyName) return null;
const baseName = safeSegment(projectName, projectId);
const count = seen.get(baseName) ?? 0;
seen.set(baseName, count + 1);
const virtualName = count === 0 ? baseName : `${baseName} (${safeSegment(companyName, projectId)})`;
const companyFolder = safeSegment(companyName, companyName);
const projectFolder = safeSegment(projectName, projectId);
return {
id: projectId,
name: projectName,
companyName,
virtualName,
virtualRoot: `/${virtualName}`,
realRoot: `/Clients/${companyFolder}/Projects/${projectFolder}`,
};
})
.filter(Boolean)
.sort((a, b) => a!.virtualName.localeCompare(b!.virtualName)) as ExternalProject[];
}
async function getUserAccess(userId: string): Promise<Access | null> {
const sb = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY);
const { data: profile } = await sb.from('profiles').select('role, company_id').eq('id', userId).single();
if (!profile) return null;
if (profile.role === 'team') return { role: 'team', roots: ['*'] };
if (profile.role === 'external') {
const projects = await getExternalProjects(sb, userId);
return {
role: 'external',
roots: ['/Team', ...projects.map(project => project.virtualRoot)],
projects,
};
}
if (profile.role === 'client') {
const roots: string[] = [];
if (profile.company_id) {
const { data: co } = await sb.from('companies').select('name').eq('id', profile.company_id).single();
if (co?.name) roots.push(`/Clients/${co.name}`);
}
const { data: memberships } = await sb.from('company_members').select('company:companies(name)').eq('profile_id', userId);
(memberships ?? []).forEach((m: any) => {
const n = m?.company?.name;
if (n && !roots.includes(`/Clients/${n}`)) roots.push(`/Clients/${n}`);
});
return roots.length ? { role: 'client', roots } : null;
}
return null;
}
async function listVirtualClientsRoot(roots: string[]) {
const folders = roots
.filter((root) => root.startsWith('/Clients/'))
.map((root) => root.replace(/\/+$/, ''))
.map((root) => {
const name = root.split('/').filter(Boolean).pop() ?? '';
return {
name,
filename: name,
path: root,
type: 'directory',
isDir: true,
size: 0,
};
})
.sort((a, b) => a.name.localeCompare(b.name));
return { folders, files: [] };
}
function listVirtualExternalRoot(projects: ExternalProject[]) {
return {
folders: [
{ name: 'Team', filename: 'Team', path: '/Team', type: 'directory', isDir: true, size: 0 },
...projects.map((project) => ({
name: project.virtualName,
filename: project.virtualName,
path: project.virtualRoot,
type: 'directory',
isDir: true,
size: 0,
})),
],
files: [],
};
}
function resolvePath(path: string, access: Access) {
const normalized = normalizePath(path);
if (access.role !== 'external') return normalized;
if (normalized === '/') return normalized;
if (normalized === '/Team' || normalized.startsWith('/Team/')) {
return normalized.replace(/^\/Team(?=\/|$)/, '/Team');
}
const project = access.projects.find((entry) => normalized === entry.virtualRoot || normalized.startsWith(entry.virtualRoot + '/'));
if (!project) return normalized;
const suffix = normalized.slice(project.virtualRoot.length);
return `${project.realRoot}${suffix}` || project.realRoot;
}
const fbqHeaders = { Authorization: `Bearer ${FBQ_TOKEN}` };
Deno.serve(async (req) => {
const origin = req.headers.get('origin') ?? '*';
if (req.method === 'OPTIONS') return new Response(null, { headers: cors(origin) });
const authHeader = req.headers.get('authorization') ?? '';
if (!authHeader.startsWith('Bearer ')) return json({ error: 'Unauthorized' }, 401, origin);
const sb = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY);
const { data: { user }, error } = await sb.auth.getUser(authHeader.slice(7));
if (error || !user) return json({ error: 'Unauthorized' }, 401, origin);
const access = await getUserAccess(user.id);
if (!access) return json({ error: 'Forbidden' }, 403, origin);
const op = req.headers.get('x-operation') ?? '';
const path = req.headers.get('x-path') ?? '/';
if (!pathAllowed(path, access)) return json({ error: 'Forbidden' }, 403, origin);
const resolvedPath = resolvePath(path, access);
if (op === 'list') {
if (access.role === 'client' && path.replace(/\/+$/, '') === '/Clients' && !access.roots.includes('*')) {
return json(await listVirtualClientsRoot(access.roots), 200, origin);
}
if (access.role === 'external' && normalizePath(path) === '/') {
return json(listVirtualExternalRoot(access.projects), 200, origin);
}
const res = await fetch(`${FBQ_URL}/api/resources?source=${SOURCE}&path=${encodeURIComponent(resolvedPath)}`, { headers: fbqHeaders });
const body = await res.text();
return new Response(body, { status: res.status, headers: { ...cors(origin), 'Content-Type': 'application/json' } });
}
if (op === 'archive') {
const files = JSON.parse(req.headers.get('x-files') ?? '[]') as string[];
const params = new URLSearchParams({ source: SOURCE });
files.map(f => resolvePath(f, access)).forEach(f => params.append('files[]', f));
const res = await fetch(`${FBQ_URL}/api/archive?${params.toString()}`, { headers: fbqHeaders });
const cd = `attachment; filename="download.zip"`;
return new Response(res.body, { status: res.status, headers: { ...cors(origin), 'Content-Type': 'application/zip', 'Content-Disposition': cd } });
}
if (op === 'download') {
const rawUrl = `${FBQ_URL}/api/raw?source=${SOURCE}&path=${encodeURIComponent(resolvedPath)}`;
const rawRes = await fetch(rawUrl, { headers: fbqHeaders });
if (rawRes.ok) {
const ct = rawRes.headers.get('content-type') ?? 'application/octet-stream';
const cd = rawRes.headers.get('content-disposition') ?? `attachment; filename="${resolvedPath.split('/').pop()}"`;
return new Response(rawRes.body, { status: rawRes.status, headers: { ...cors(origin), 'Content-Type': ct, 'Content-Disposition': cd } });
}
const rawErrorText = await rawRes.text();
const shouldFallbackToResourcesDownload = rawRes.status === 400 && rawErrorText.toLowerCase().includes('no files specified');
if (!shouldFallbackToResourcesDownload) {
return new Response(rawErrorText, { status: rawRes.status, headers: { ...cors(origin), 'Content-Type': 'application/json' } });
}
const directDownloadUrl = `${FBQ_URL}/api/resources/download?source=${SOURCE}&file=${encodeURIComponent(resolvedPath)}`;
const directRes = await fetch(directDownloadUrl, { headers: fbqHeaders });
const directCt = directRes.headers.get('content-type') ?? 'application/octet-stream';
const directCd = directRes.headers.get('content-disposition') ?? `attachment; filename="${resolvedPath.split('/').pop() ?? 'download'}"`;
return new Response(directRes.body, {
status: directRes.status,
headers: { ...cors(origin), 'Content-Type': directCt, 'Content-Disposition': directCd },
});
}
if (op === 'upload') {
const body = await req.arrayBuffer();
const ct = req.headers.get('content-type') ?? '';
const res = await fetch(`${FBQ_URL}/api/resources?source=${SOURCE}&path=${encodeURIComponent(resolvedPath)}`, {
method: 'POST', headers: { ...fbqHeaders, 'Content-Type': ct }, body,
});
const text = await res.text();
return new Response(text, { status: res.status, headers: { ...cors(origin), 'Content-Type': 'application/json' } });
}
if (op === 'delete') {
const files = JSON.parse(req.headers.get('x-files') ?? '[]') as string[];
await Promise.all(files.map(f =>
fetch(`${FBQ_URL}/api/resources?source=${SOURCE}&path=${encodeURIComponent(resolvePath(f, access))}`, { method: 'DELETE', headers: fbqHeaders })
));
return json({ ok: true }, 200, origin);
}
if (op === 'copy' || op === 'move') {
const srcPaths = JSON.parse(req.headers.get('x-files') ?? '[]') as string[];
const destDir = resolvedPath.replace(/\/+$/, '');
const items = srcPaths.map(src => {
const resolved = resolvePath(src, access);
const name = resolved.split('/').filter(Boolean).pop() ?? '';
return { fromSource: SOURCE, fromPath: resolved, toSource: SOURCE, toPath: `${destDir}/${name}` };
});
const res = await fetch(`${FBQ_URL}/api/resources?source=${SOURCE}`, {
method: 'PATCH',
headers: { ...fbqHeaders, 'Content-Type': 'application/json' },
body: JSON.stringify({ action: op, items, overwrite: false }),
});
const text = await res.text();
return new Response(text, { status: res.status, headers: { ...cors(origin), 'Content-Type': 'application/json' } });
}
if (op === 'mkdir') {
const dirPath = resolvedPath.endsWith('/') ? resolvedPath : resolvedPath + '/';
const res = await fetch(`${FBQ_URL}/api/resources?source=${SOURCE}&path=${encodeURIComponent(dirPath)}&isDir=true`, {
method: 'POST', headers: fbqHeaders,
});
const text = await res.text();
return new Response(text, { status: res.status, headers: { ...cors(origin), 'Content-Type': 'application/json' } });
}
return json({ error: 'Unknown operation' }, 400, origin);
});
+274 -33
View File
@@ -4,6 +4,7 @@ import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
const RESEND_API_KEY = Deno.env.get('RESEND_API_KEY'); const RESEND_API_KEY = Deno.env.get('RESEND_API_KEY');
const SUPABASE_URL = Deno.env.get('SUPABASE_URL'); const SUPABASE_URL = Deno.env.get('SUPABASE_URL');
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY'); const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY');
const SUPABASE_WEBHOOK_SECRET = Deno.env.get('SUPABASE_WEBHOOK_SECRET');
const FROM = 'Fourge Branding <hello@fourgebranding.com>'; const FROM = 'Fourge Branding <hello@fourgebranding.com>';
const TEAM_EMAILS = [ const TEAM_EMAILS = [
'krao@fourgebranding.com', 'krao@fourgebranding.com',
@@ -12,7 +13,7 @@ const TEAM_EMAILS = [
'twebb@fourgebranding.com', 'twebb@fourgebranding.com',
]; ];
const ALLOWED_TYPES = ['new_request', 'sent_to_client', 'revision_submitted', 'client_approved', 'invoice_sent', 'receipt_sent', 'subcontractor_po_sent', 'subcontractor_invoice_submitted'] as const; const ALLOWED_TYPES = ['new_request', 'sent_to_client', 'revision_submitted', 'client_approved', 'invoice_sent', 'receipt_sent', 'subcontractor_po_sent', 'subcontractor_invoice_submitted', 'task_status_update'] as const;
type EmailType = typeof ALLOWED_TYPES[number]; type EmailType = typeof ALLOWED_TYPES[number];
// Types that only team members may trigger // Types that only team members may trigger
@@ -53,6 +54,8 @@ const optStr = (val: unknown, max = 500): string => {
return val.trim(); return val.trim();
}; };
const optBool = (val: unknown): boolean => val === true;
/** Basic UUID format check. */ /** Basic UUID format check. */
const isUuid = (v: unknown): v is string => const isUuid = (v: unknown): v is string =>
typeof v === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v); typeof v === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v);
@@ -102,26 +105,32 @@ serve(async (req) => {
// ── 1. Authentication ──────────────────────────────────────────────────── // ── 1. Authentication ────────────────────────────────────────────────────
const authHeader = req.headers.get('Authorization') ?? ''; const authHeader = req.headers.get('Authorization') ?? '';
const accessToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : ''; const accessToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
if (!accessToken) { const webhookSecret = req.headers.get('x-webhook-secret') ?? '';
const isInternalWebhook = !!SUPABASE_WEBHOOK_SECRET && webhookSecret === SUPABASE_WEBHOOK_SECRET;
if (!accessToken && !isInternalWebhook) {
return respond({ error: 'Unauthorized' }, 401); return respond({ error: 'Unauthorized' }, 401);
} }
// Use service role client to validate the token — works with ES256 JWTs // Use service role client to validate the token — works with ES256 JWTs
const adminClient = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY); const adminClient = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
const { data: userData, error: authError } = await adminClient.auth.getUser(accessToken); let userData: { user?: { id: string } } | null = null;
if (authError || !userData?.user) { if (!isInternalWebhook) {
return respond({ error: `Auth failed: ${authError?.message ?? 'no user'}` }, 401); const authResult = await adminClient.auth.getUser(accessToken);
userData = authResult.data;
if (authResult.error || !userData?.user) {
return respond({ error: `Auth failed: ${authResult.error?.message ?? 'no user'}` }, 401);
}
} }
// ── 2. Role lookup ─────────────────────────────────────────────────────── // ── 2. Role lookup ───────────────────────────────────────────────────────
const { data: profile } = await adminClient const role: string = isInternalWebhook
? 'team'
: ((await adminClient
.from('profiles') .from('profiles')
.select('role') .select('role')
.eq('id', userData.user.id) .eq('id', userData?.user?.id)
.single(); .single()).data?.role ?? '');
const role: string = profile?.role ?? '';
if (!['team', 'client', 'external'].includes(role)) { if (!['team', 'client', 'external'].includes(role)) {
return respond({ error: 'Forbidden' }, 403); return respond({ error: 'Forbidden' }, 403);
} }
@@ -163,17 +172,55 @@ serve(async (req) => {
subject = `New Request: ${esc(serviceType)}${esc(clientName)}`; subject = `New Request: ${esc(serviceType)}${esc(clientName)}`;
html = ` html = `
<h2>New Request Received</h2> <div style="font-family:sans-serif;max-width:560px;margin:0 auto;color:#1a1a1a;">
<p><strong>From:</strong> ${esc(clientName)} (${esc(clientEmail)})</p> <div style="background:#141414;padding:20px 28px;border-radius:8px 8px 0 0;">
<p><strong>Company:</strong> ${esc(company) || '—'}</p> <img src="https://portal.fourgebranding.com/fourge-logo.png" alt="Fourge Branding" style="height:28px;" />
<p><strong>Service:</strong> ${esc(serviceType)}</p> </div>
<p><strong>Project:</strong> ${esc(projectName)}</p> <div style="background:#fff;padding:28px;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 8px 8px;">
${deadline ? `<p><strong>Deadline:</strong> ${esc(deadline)}</p>` : ''} <h2 style="margin:0 0 8px;font-size:20px;">New Request Received</h2>
<hr /> <p style="color:#555;margin:0 0 24px;">A new request has been created in the portal.</p>
<p><strong>Description:</strong></p>
<p>${escMultiline(description)}</p> <table style="width:100%;border-collapse:collapse;margin-bottom:24px;">
<br /> <tr style="background:#f9f9f9;">
<a href="https://portal.fourgebranding.com/tasks/${esc(taskId)}" style="background:#F5A523;color:#1a1a1a;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">View Job</a> <td style="padding:10px 14px;font-size:13px;color:#666;">From</td>
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(clientName)}</td>
</tr>
<tr>
<td style="padding:10px 14px;font-size:13px;color:#666;">Email</td>
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(clientEmail)}</td>
</tr>
${company ? `
<tr style="background:#f9f9f9;">
<td style="padding:10px 14px;font-size:13px;color:#666;">Company</td>
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(company)}</td>
</tr>` : ''}
<tr>
<td style="padding:10px 14px;font-size:13px;color:#666;">Service</td>
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(serviceType)}</td>
</tr>
<tr style="background:#f9f9f9;">
<td style="padding:10px 14px;font-size:13px;color:#666;">Project</td>
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(projectName)}</td>
</tr>
${deadline ? `
<tr>
<td style="padding:10px 14px;font-size:13px;color:#666;">Deadline</td>
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(deadline)}</td>
</tr>` : ''}
</table>
<div style="background:#f9f9f9;padding:12px 14px;border-radius:6px;font-size:13px;color:#555;margin-bottom:24px;">
<strong>Description</strong><br />
${escMultiline(description)}
</div>
<a href="https://portal.fourgebranding.com/tasks/${esc(taskId)}" style="display:block;background:#141414;color:#fff;text-align:center;padding:14px;border-radius:8px;text-decoration:none;font-weight:700;font-size:16px;margin-bottom:20px;">View Task</a>
<p style="font-size:12px;color:#999;text-align:center;margin:0;">
Questions? <a href="mailto:hello@fourgebranding.com" style="color:#555;">hello@fourgebranding.com</a>
</p>
</div>
</div>
`; `;
} }
@@ -210,16 +257,50 @@ serve(async (req) => {
subject = `Revision Request: ${esc(serviceType)}${esc(clientName)}`; subject = `Revision Request: ${esc(serviceType)}${esc(clientName)}`;
html = ` html = `
<h2>Revision Requested</h2> <div style="font-family:sans-serif;max-width:560px;margin:0 auto;color:#1a1a1a;">
<p><strong>From:</strong> ${esc(clientName)}</p> <div style="background:#141414;padding:20px 28px;border-radius:8px 8px 0 0;">
<p><strong>Job:</strong> ${esc(serviceType)} ${esc(projectName)}</p> <img src="https://portal.fourgebranding.com/fourge-logo.png" alt="Fourge Branding" style="height:28px;" />
<p><strong>New Version:</strong> ${esc(version)}</p> </div>
${deadline ? `<p><strong>New Deadline:</strong> ${esc(deadline)}</p>` : ''} <div style="background:#fff;padding:28px;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 8px 8px;">
<hr /> <h2 style="margin:0 0 8px;font-size:20px;">Revision Requested</h2>
<p><strong>Requested changes:</strong></p> <p style="color:#555;margin:0 0 24px;">A revision was requested in the portal.</p>
<p>${escMultiline(description)}</p>
<br /> <table style="width:100%;border-collapse:collapse;margin-bottom:24px;">
<a href="https://portal.fourgebranding.com/tasks/${esc(taskId)}" style="background:#F5A523;color:#1a1a1a;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">View Job</a> <tr style="background:#f9f9f9;">
<td style="padding:10px 14px;font-size:13px;color:#666;">From</td>
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(clientName)}</td>
</tr>
<tr>
<td style="padding:10px 14px;font-size:13px;color:#666;">Task</td>
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(serviceType)}</td>
</tr>
<tr style="background:#f9f9f9;">
<td style="padding:10px 14px;font-size:13px;color:#666;">Project</td>
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(projectName)}</td>
</tr>
<tr>
<td style="padding:10px 14px;font-size:13px;color:#666;">Version</td>
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(version)}</td>
</tr>
${deadline ? `
<tr style="background:#f9f9f9;">
<td style="padding:10px 14px;font-size:13px;color:#666;">Deadline</td>
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(deadline)}</td>
</tr>` : ''}
</table>
<div style="background:#f9f9f9;padding:12px 14px;border-radius:6px;font-size:13px;color:#555;margin-bottom:24px;">
<strong>Requested changes</strong><br />
${escMultiline(description)}
</div>
<a href="https://portal.fourgebranding.com/tasks/${esc(taskId)}" style="display:block;background:#141414;color:#fff;text-align:center;padding:14px;border-radius:8px;text-decoration:none;font-weight:700;font-size:16px;margin-bottom:20px;">View Task</a>
<p style="font-size:12px;color:#999;text-align:center;margin:0;">
Questions? <a href="mailto:hello@fourgebranding.com" style="color:#555;">hello@fourgebranding.com</a>
</p>
</div>
</div>
`; `;
} }
@@ -428,12 +509,168 @@ serve(async (req) => {
`; `;
} }
else if (type === 'task_status_update') {
const taskTitle = requireStr(data?.taskTitle);
const statusLabel = requireStr(data?.statusLabel);
const message = requireStr(data?.message, 10000);
const taskId = data?.taskId;
if (!isUuid(taskId)) throw new Error('Invalid taskId');
const subjectLine = optStr(data?.subject, 200) || `${statusLabel}: ${taskTitle}`;
const headline = optStr(data?.headline, 200) || statusLabel;
const projectName = optStr(data?.projectName, 200);
const companyName = optStr(data?.companyName, 200);
const buttonLabel = optStr(data?.buttonLabel, 80) || 'View Task';
subject = subjectLine;
html = `
<div style="font-family:sans-serif;max-width:560px;margin:0 auto;color:#1a1a1a;">
<div style="background:#141414;padding:20px 28px;border-radius:8px 8px 0 0;">
<img src="https://portal.fourgebranding.com/fourge-logo.png" alt="Fourge Branding" style="height:28px;" />
</div>
<div style="background:#fff;padding:28px;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 8px 8px;">
<h2 style="margin:0 0 8px;font-size:20px;">${esc(headline)}</h2>
<p style="color:#555;margin:0 0 24px;">${escMultiline(message)}</p>
<table style="width:100%;border-collapse:collapse;margin-bottom:24px;">
<tr style="background:#f9f9f9;">
<td style="padding:10px 14px;font-size:13px;color:#666;">Task</td>
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(taskTitle)}</td>
</tr>
${projectName ? `
<tr>
<td style="padding:10px 14px;font-size:13px;color:#666;">Project</td>
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(projectName)}</td>
</tr>` : ''}
${companyName ? `
<tr style="background:#f9f9f9;">
<td style="padding:10px 14px;font-size:13px;color:#666;">Company</td>
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(companyName)}</td>
</tr>` : ''}
<tr>
<td style="padding:10px 14px;font-size:13px;color:#666;">Status</td>
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(statusLabel)}</td>
</tr>
</table>
<a href="https://portal.fourgebranding.com/tasks/${esc(taskId)}" style="display:block;background:#141414;color:#fff;text-align:center;padding:14px;border-radius:8px;text-decoration:none;font-weight:700;font-size:16px;margin-bottom:20px;">${esc(buttonLabel)}</a>
<p style="font-size:12px;color:#999;text-align:center;margin:0;">
Questions? <a href="mailto:hello@fourgebranding.com" style="color:#555;">hello@fourgebranding.com</a>
</p>
</div>
</div>
`;
}
// ── 5. Resolve recipients ──────────────────────────────────────────────── // ── 5. Resolve recipients ────────────────────────────────────────────────
const teamTypes = ['new_request', 'revision_submitted', 'client_approved', 'subcontractor_invoice_submitted']; const teamTypes = ['revision_submitted', 'client_approved'];
let recipients: string[]; let recipients: string[];
let cc: string[] | undefined; let cc: string[] | undefined;
if (teamTypes.includes(type)) { if (type === 'task_status_update') {
const includeTeam = optBool(data?.includeTeam);
const includeAssigned = optBool(data?.includeAssigned);
const includeClient = optBool(data?.includeClient);
const includeProjectMembers = optBool(data?.includeProjectMembers);
const assignedProfileId = data?.assignedProfileId;
const companyId = data?.companyId;
const projectId = data?.projectId;
const recipientStrategy = optStr(data?.recipientStrategy, 80);
const recipientSet = new Set<string>();
if (recipientStrategy === 'revision_request') {
if (isUuid(assignedProfileId)) {
const { data: assignedProfile } = await adminClient
.from('profiles')
.select('email, role')
.eq('id', assignedProfileId)
.single();
if (assignedProfile?.role === 'external') {
if (isEmail(assignedProfile?.email)) recipientSet.add(assignedProfile.email);
} else if (assignedProfile?.role === 'team') {
TEAM_EMAILS.forEach(email => recipientSet.add(email));
} else {
TEAM_EMAILS.forEach(email => recipientSet.add(email));
if (isUuid(projectId)) {
const { data: memberships } = await adminClient
.from('project_members')
.select('profile:profiles!inner(email, role)')
.eq('project_id', projectId);
for (const membership of (memberships || [])) {
const profile = Array.isArray(membership.profile) ? membership.profile[0] : membership.profile;
if (profile?.role === 'external' && isEmail(profile?.email)) recipientSet.add(profile.email);
}
}
}
} else {
TEAM_EMAILS.forEach(email => recipientSet.add(email));
if (isUuid(projectId)) {
const { data: memberships } = await adminClient
.from('project_members')
.select('profile:profiles!inner(email, role)')
.eq('project_id', projectId);
for (const membership of (memberships || [])) {
const profile = Array.isArray(membership.profile) ? membership.profile[0] : membership.profile;
if (profile?.role === 'external' && isEmail(profile?.email)) recipientSet.add(profile.email);
}
}
}
} else {
if (includeTeam) TEAM_EMAILS.forEach(email => recipientSet.add(email));
if (includeAssigned && isUuid(assignedProfileId)) {
const { data: assignedProfile } = await adminClient
.from('profiles')
.select('email')
.eq('id', assignedProfileId)
.single();
if (isEmail(assignedProfile?.email)) recipientSet.add(assignedProfile.email);
}
if (includeClient && isUuid(companyId)) {
const [{ data: company }, { data: clientProfiles }] = await Promise.all([
adminClient.from('companies').select('contact_email').eq('id', companyId).single(),
adminClient.from('profiles').select('email').eq('company_id', companyId).eq('role', 'client'),
]);
if (isEmail(company?.contact_email)) recipientSet.add(company.contact_email);
for (const profile of (clientProfiles || [])) {
if (isEmail(profile?.email)) recipientSet.add(profile.email);
}
}
if (includeProjectMembers && isUuid(projectId)) {
const { data: memberships } = await adminClient
.from('project_members')
.select('profile:profiles!inner(email)')
.eq('project_id', projectId);
for (const membership of (memberships || [])) {
const profile = Array.isArray(membership.profile) ? membership.profile[0] : membership.profile;
if (isEmail(profile?.email)) recipientSet.add(profile.email);
}
}
}
recipients = [...recipientSet].filter(isEmail);
if (recipients.length === 0) throw new Error('No valid recipient emails');
}
else if (type === 'new_request') {
const recipientSet = new Set<string>();
TEAM_EMAILS.forEach(email => recipientSet.add(email));
recipients = [...recipientSet].filter(isEmail);
if (recipients.length === 0) throw new Error('No valid recipient emails');
}
else if (type === 'subcontractor_invoice_submitted') {
const recipientSet = new Set<string>();
TEAM_EMAILS.forEach(email => recipientSet.add(email));
const subEmail = optStr(data?.subEmail);
if (isEmail(subEmail)) recipientSet.add(subEmail);
recipients = [...recipientSet].filter(isEmail);
if (recipients.length === 0) throw new Error('No valid recipient emails');
}
else if (teamTypes.includes(type)) {
recipients = TEAM_EMAILS; recipients = TEAM_EMAILS;
} else { } else {
// Validate caller-supplied recipient list // Validate caller-supplied recipient list
@@ -444,6 +681,10 @@ serve(async (req) => {
const senderEmail = optStr(data?.senderEmail); const senderEmail = optStr(data?.senderEmail);
if (senderEmail && isEmail(senderEmail)) cc = [senderEmail]; if (senderEmail && isEmail(senderEmail)) cc = [senderEmail];
if (type === 'invoice_sent' || type === 'receipt_sent') {
TEAM_EMAILS.forEach(email => recipients.push(email));
recipients = [...new Set(recipients.filter(isEmail))];
}
if (type === 'invoice_sent') { if (type === 'invoice_sent') {
cc = [...new Set([...(cc || []), 'hello@fourgebranding.com'])]; cc = [...new Set([...(cc || []), 'hello@fourgebranding.com'])];
} }
@@ -4,6 +4,7 @@ import Stripe from 'https://esm.sh/stripe@14?target=deno';
const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!, { apiVersion: '2023-10-16' }); const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!, { apiVersion: '2023-10-16' });
const webhookSecret = Deno.env.get('STRIPE_WEBHOOK_SECRET')!; const webhookSecret = Deno.env.get('STRIPE_WEBHOOK_SECRET')!;
const internalWebhookSecret = Deno.env.get('SUPABASE_WEBHOOK_SECRET')!;
serve(async (req) => { serve(async (req) => {
const body = await req.text(); const body = await req.text();
@@ -42,6 +43,31 @@ serve(async (req) => {
}; };
if (stripe_fee !== null) updateData.stripe_fee = stripe_fee; if (stripe_fee !== null) updateData.stripe_fee = stripe_fee;
await supabase.from('invoices').update(updateData).eq('id', invoice_id); await supabase.from('invoices').update(updateData).eq('id', invoice_id);
const { data: invoice } = await supabase
.from('invoices')
.select('invoice_number, invoice_email, bill_to, total, paid_at, company_id')
.eq('id', invoice_id)
.single();
if (!invoice?.invoice_email) return;
await fetch(`${Deno.env.get('SUPABASE_URL')}/functions/v1/send-email`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-webhook-secret': internalWebhookSecret,
},
body: JSON.stringify({
type: 'receipt_sent',
to: [invoice.invoice_email],
data: {
invoiceNumber: invoice.invoice_number,
billTo: invoice.bill_to || invoice.invoice_email,
total: `$${Number(invoice.total || 0).toFixed(2)}`,
paidDate: new Date(invoice.paid_at || new Date().toISOString()).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }),
},
}),
});
} }
// Card payments: session completes with payment_status = 'paid' immediately // Card payments: session completes with payment_status = 'paid' immediately
@@ -0,0 +1,2 @@
-- Placeholder to preserve migration history parity with the remote Supabase project.
-- The original SQL for this already-applied remote migration is not present in the repo.
@@ -0,0 +1,2 @@
-- Placeholder to preserve migration history parity with the remote Supabase project.
-- The original SQL for this already-applied remote migration is not present in the repo.
@@ -0,0 +1,2 @@
-- Placeholder to preserve migration history parity with the remote Supabase project.
-- The original SQL for this already-applied remote migration is not present in the repo.
@@ -0,0 +1,2 @@
-- Placeholder to preserve migration history parity with the remote Supabase project.
-- The original SQL for this already-applied remote migration is not present in the repo.
@@ -0,0 +1,2 @@
-- Placeholder to preserve migration history parity with the remote Supabase project.
-- The original SQL for this already-applied remote migration is not present in the repo.
@@ -0,0 +1,3 @@
-- Only team members may delete subcontractor invoices.
drop policy if exists "Sub delete own draft invoices" on public.subcontractor_invoices;
drop policy if exists "Sub delete own unpaid invoices" on public.subcontractor_invoices;
@@ -0,0 +1,19 @@
-- Prevent deletion of paid client invoices for all roles.
create or replace function public.prevent_paid_invoice_delete()
returns trigger
language plpgsql
as $$
begin
if old.status = 'paid' then
raise exception 'Paid invoices cannot be deleted.';
end if;
return old;
end;
$$;
drop trigger if exists prevent_paid_invoice_delete on public.invoices;
create trigger prevent_paid_invoice_delete
before delete on public.invoices
for each row
execute function public.prevent_paid_invoice_delete();
@@ -0,0 +1,200 @@
create or replace function public.get_my_role()
returns text as $$
select role from public.profiles where id = auth.uid();
$$ language sql security definer stable
set search_path = public;
create or replace function public.is_external()
returns boolean as $$
select get_my_role() = 'external';
$$ language sql security definer stable
set search_path = public;
create or replace function public.get_my_company_id()
returns uuid as $$
select company_id from public.profiles where id = auth.uid();
$$ language sql security definer stable
set search_path = public;
create or replace function public.has_company_access(company uuid)
returns boolean as $$
select exists (
select 1
from public.profiles p
where p.id = auth.uid()
and (
p.company_id = company
or exists (
select 1
from public.company_members cm
where cm.profile_id = auth.uid()
and cm.company_id = company
)
)
);
$$ language sql security definer stable
set search_path = public;
create or replace function public.guard_task_update()
returns trigger as $$
declare
caller_role text;
begin
select role into caller_role from public.profiles where id = auth.uid();
if caller_role = 'client' then
new.project_id := old.project_id;
new.invoiced := old.invoiced;
if not (
new.status = 'not_started'
and coalesce(new.current_version, 0) > coalesce(old.current_version, 0)
and new.assigned_to is null
and new.assigned_name is null
) then
new.assigned_to := old.assigned_to;
new.assigned_name := old.assigned_name;
end if;
elsif caller_role = 'external' then
new.project_id := old.project_id;
new.invoiced := old.invoiced;
end if;
return new;
end;
$$ language plpgsql security definer
set search_path = public;
create or replace function public.handle_new_user()
returns trigger as $$
begin
insert into public.profiles (id, name, email, role)
values (
new.id,
coalesce(new.raw_user_meta_data->>'name', ''),
new.email,
coalesce(new.raw_user_meta_data->>'role', 'client')
);
return new;
end;
$$ language plpgsql security definer
set search_path = public;
create or replace function public.prevent_duplicate_active_subcontractor_po_task()
returns trigger as $$
declare
current_po_status text;
duplicate_po_number text;
begin
if new.task_id is null then
return new;
end if;
select status into current_po_status
from public.subcontractor_payments
where id = new.po_id;
if current_po_status = 'cancelled' then
return new;
end if;
select sp.po_number into duplicate_po_number
from public.subcontractor_po_items item
join public.subcontractor_payments sp on sp.id = item.po_id
where item.task_id = new.task_id
and item.id <> coalesce(new.id, '00000000-0000-0000-0000-000000000000'::uuid)
and sp.status <> 'cancelled'
limit 1;
if duplicate_po_number is not null then
raise exception 'Task is already attached to active subcontractor PO %', duplicate_po_number
using errcode = '23505';
end if;
return new;
end;
$$ language plpgsql security definer
set search_path = public;
create or replace function public.prevent_duplicate_on_subcontractor_po_reactivation()
returns trigger as $$
declare
duplicate_task_title text;
begin
if old.status = new.status or new.status = 'cancelled' then
return new;
end if;
select t.title into duplicate_task_title
from public.subcontractor_po_items current_item
join public.subcontractor_po_items other_item
on other_item.task_id = current_item.task_id
and other_item.po_id <> current_item.po_id
join public.subcontractor_payments other_po
on other_po.id = other_item.po_id
and other_po.status <> 'cancelled'
left join public.tasks t on t.id = current_item.task_id
where current_item.po_id = new.id
and current_item.task_id is not null
limit 1;
if duplicate_task_title is not null then
raise exception 'Cannot reactivate PO because task "%" is already attached to another active subcontractor PO', duplicate_task_title
using errcode = '23505';
end if;
return new;
end;
$$ language plpgsql security definer
set search_path = public;
create or replace function public.sync_subcontractor_project_member()
returns trigger as $$
begin
if new.profile_id is not null and new.project_id is not null then
insert into public.project_members (project_id, profile_id)
values (new.project_id, new.profile_id)
on conflict (project_id, profile_id) do nothing;
end if;
return new;
end;
$$ language plpgsql security definer
set search_path = public;
create or replace function public.sync_project_member_on_task_assign()
returns trigger as $$
begin
if new.assigned_to is not null and new.project_id is not null then
insert into public.project_members (project_id, profile_id)
values (new.project_id, new.assigned_to)
on conflict (project_id, profile_id) do nothing;
end if;
return new;
end;
$$ language plpgsql security definer
set search_path = public;
do $$
declare
fn record;
begin
for fn in
select n.nspname as schema_name, p.proname as function_name, pg_get_function_identity_arguments(p.oid) as identity_args
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public'
and p.proname in (
'notify_company_folder_sync',
'notify_profile_folder_sync',
'sync_project_status'
)
loop
execute format(
'drop function if exists %I.%I(%s) cascade;',
fn.schema_name,
fn.function_name,
fn.identity_args
);
end loop;
end
$$;
@@ -0,0 +1,5 @@
update public.submissions as s
set submitted_by_name = p.name
from public.profiles as p
where s.submitted_by = p.id
and coalesce(s.submitted_by_name, '') is distinct from coalesce(p.name, '');
@@ -0,0 +1,64 @@
alter table if exists public.submission_signs enable row level security;
drop policy if exists "Team all submission_signs" on public.submission_signs;
create policy "Team all submission_signs" on public.submission_signs
for all
using (get_my_role() = 'team')
with check (get_my_role() = 'team');
drop policy if exists "Client reads assigned company submission_signs" on public.submission_signs;
create policy "Client reads assigned company submission_signs" on public.submission_signs
for select
using (
submission_id in (
select s.id
from public.submissions s
join public.tasks t on t.id = s.task_id
join public.projects p on p.id = t.project_id
where p.company_id = get_my_company_id()
)
);
drop policy if exists "Client inserts assigned company submission_signs" on public.submission_signs;
create policy "Client inserts assigned company submission_signs" on public.submission_signs
for insert
with check (
get_my_role() = 'client'
and submission_id in (
select s.id
from public.submissions s
join public.tasks t on t.id = s.task_id
join public.projects p on p.id = t.project_id
where p.company_id = get_my_company_id()
and s.submitted_by = auth.uid()
)
);
drop policy if exists "External reads assigned submission_signs" on public.submission_signs;
create policy "External reads assigned submission_signs" on public.submission_signs
for select
using (
get_my_role() = 'external'
and submission_id in (
select s.id
from public.submissions s
join public.tasks t on t.id = s.task_id
join public.project_members pm on pm.project_id = t.project_id
where pm.profile_id = auth.uid()
)
);
drop policy if exists "External inserts assigned submission_signs" on public.submission_signs;
create policy "External inserts assigned submission_signs" on public.submission_signs
for insert
with check (
get_my_role() = 'external'
and submission_id in (
select s.id
from public.submissions s
join public.tasks t on t.id = s.task_id
join public.project_members pm on pm.project_id = t.project_id
where pm.profile_id = auth.uid()
and s.submitted_by = auth.uid()
)
);
@@ -0,0 +1,21 @@
alter table public.submissions
add column if not exists signs jsonb not null default '[]'::jsonb;
update public.submissions s
set signs = legacy.signs
from (
select
submission_id,
jsonb_agg(
jsonb_build_object(
'sign_number', sign_number,
'sign_name', sign_name,
'sign_family', sign_family
)
order by sign_number
) as signs
from public.submission_signs
group by submission_id
) as legacy
where s.id = legacy.submission_id
and coalesce(s.signs, '[]'::jsonb) = '[]'::jsonb;
@@ -0,0 +1,23 @@
update public.submissions s
set signs = legacy.signs
from (
select
submission_id,
jsonb_agg(
jsonb_build_object(
'sign_number', sign_number,
'sign_name', sign_name,
'sign_family', sign_family
)
order by sign_number
) as signs
from public.submission_signs
group by submission_id
) as legacy
where s.id = legacy.submission_id
and (
s.signs is null
or s.signs = '[]'::jsonb
);
drop table if exists public.submission_signs cascade;
+15 -5
View File
@@ -67,6 +67,9 @@ create table public.submissions (
invoiced boolean not null default false, invoiced boolean not null default false,
is_hot boolean not null default false, is_hot boolean not null default false,
service_type text default '', service_type text default '',
sign_count integer,
sign_family text default '',
signs jsonb not null default '[]'::jsonb,
deadline date, deadline date,
description text default '', description text default '',
submitted_by uuid references public.profiles(id) on delete set null, submitted_by uuid references public.profiles(id) on delete set null,
@@ -280,17 +283,20 @@ alter table public.fourge_passwords enable row level security;
create or replace function public.get_my_role() create or replace function public.get_my_role()
returns text as $$ returns text as $$
select role from public.profiles where id = auth.uid(); select role from public.profiles where id = auth.uid();
$$ language sql security definer stable; $$ language sql security definer stable
set search_path = public;
create or replace function public.is_external() create or replace function public.is_external()
returns boolean as $$ returns boolean as $$
select get_my_role() = 'external'; select get_my_role() = 'external';
$$ language sql security definer stable; $$ language sql security definer stable
set search_path = public;
create or replace function public.get_my_company_id() create or replace function public.get_my_company_id()
returns uuid as $$ returns uuid as $$
select company_id from public.profiles where id = auth.uid(); select company_id from public.profiles where id = auth.uid();
$$ language sql security definer stable; $$ language sql security definer stable
set search_path = public;
create or replace function public.get_database_size_bytes() create or replace function public.get_database_size_bytes()
returns bigint as $$ returns bigint as $$
@@ -326,7 +332,10 @@ begin
return new; return new;
end; end;
$$ language plpgsql security definer; $$ language plpgsql security definer
set search_path = public;
set search_path = public;
set search_path = public;
create trigger guard_task_update create trigger guard_task_update
before update on public.tasks before update on public.tasks
@@ -647,7 +656,8 @@ begin
); );
return new; return new;
end; end;
$$ language plpgsql security definer; $$ language plpgsql security definer
set search_path = public;
create trigger on_auth_user_created create trigger on_auth_user_created
after insert on auth.users after insert on auth.users
+1 -4
View File
@@ -1,6 +1,3 @@
{ {
"rewrites": [{ "source": "/((?!\\.well-known).*)", "destination": "/index.html" }], "rewrites": [{ "source": "/((?!\\.well-known).*)", "destination": "/index.html" }]
"functions": {
"api/backfill-request-files.js": { "maxDuration": 300 }
}
} }

Some files were not shown because too many files have changed in this diff Show More