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:
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"extraKnownMarketplaces": {
|
||||
"ecc": {
|
||||
"source": {
|
||||
"source": "github",
|
||||
"repo": "affaan-m/ecc"
|
||||
}
|
||||
}
|
||||
},
|
||||
"enabledPlugins": {
|
||||
"ecc@ecc": true
|
||||
}
|
||||
}
|
||||
@@ -1,77 +1,16 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(*)",
|
||||
"Bash(\"/Users/kraohasanee/Documents/40-49 Fourge:*)",
|
||||
"Bash(vercel --version)",
|
||||
"Bash(vercel --prod)",
|
||||
"Bash(supabase --version)",
|
||||
"Bash(brew install:*)",
|
||||
"Read(//usr/local/bin/**)",
|
||||
"Read(//Users/kraohasanee/.local/bin/**)",
|
||||
"Read(//usr/**)",
|
||||
"Bash(command -v supabase)",
|
||||
"Read(//Users/kraohasanee/Library/**)",
|
||||
"Bash(npx supabase:*)",
|
||||
"Bash(echo $PATH)",
|
||||
"Bash(supabase functions:*)",
|
||||
"Bash(npm install:*)",
|
||||
"Bash(export PATH=\"/opt/homebrew/bin:$PATH\")",
|
||||
"Read(//opt/homebrew/bin/**)",
|
||||
"Read(//Users/kraohasanee/.npm/bin/**)",
|
||||
"Bash(export PATH=\"/usr/local/bin:/opt/homebrew/bin:$PATH\")",
|
||||
"Read(//Users/kraohasanee/.supabase/**)",
|
||||
"Bash(supabase status:*)",
|
||||
"Bash(supabase orgs:*)",
|
||||
"Bash(supabase projects:*)",
|
||||
"Bash(supabase db:*)",
|
||||
"Bash(npm run:*)",
|
||||
"Bash(npx vercel:*)",
|
||||
"Bash(curl -vI https://portal.fourgebranding.com)",
|
||||
"Bash(dig portal.fourgebranding.com A +short)",
|
||||
"Bash(dig portal.fourgebranding.com CNAME +short)",
|
||||
"Bash(curl:*)",
|
||||
"Bash(supabase migration:*)",
|
||||
"Bash(ls \"/Users/kraohasanee/Documents/40-49 Fourge Branding/41 Website/fourge-portal\"/.env*)",
|
||||
"Bash(supabase secrets:*)",
|
||||
"Bash(stripe version:*)",
|
||||
"Bash(stripe config:*)",
|
||||
"Bash(stripe checkout:*)",
|
||||
"Bash(stripe payment_intents list --limit 10)",
|
||||
"Bash(stripe charges:*)",
|
||||
"Bash(vercel ls:*)",
|
||||
"Bash(vercel promote:*)",
|
||||
"Bash(vercel inspect:*)",
|
||||
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(d.get\\('gitSource', d.get\\('meta', ''\\)\\)\\)\")",
|
||||
"Bash(npx vite:*)",
|
||||
"Bash(wait)",
|
||||
"Bash(stripe webhook_endpoints list)",
|
||||
"Bash(grep VITE_SUPABASE_ANON_KEY .env.local)",
|
||||
"Bash(grep VITE_SUPABASE_ANON_KEY .env.production)",
|
||||
"Bash(vercel whoami *)",
|
||||
"Bash(vercel deploy *)",
|
||||
"Bash(vercel build *)",
|
||||
"Bash(vercel pull *)",
|
||||
"Bash(sudo npm *)",
|
||||
"Bash(npx vercel@latest --prod)",
|
||||
"Bash(npm run *)",
|
||||
"Bash(awk 'NR>=547 && /^}/ {print NR\": \"$0; c++; if\\(c>=1\\) exit}' src/pages/Companies.jsx)",
|
||||
"Bash(sed -n '545,548p' src/pages/Companies.jsx)",
|
||||
"Bash(sed -i '' '545,715d' src/pages/Companies.jsx)",
|
||||
"Bash(sed -n '542,548p' src/pages/Companies.jsx)",
|
||||
"Bash(sed -n '76,80p' src/pages/team/TeamSubInvoiceDetail.jsx)",
|
||||
"Bash(sed -n '147p' src/pages/Profile.jsx)",
|
||||
"Bash(awk '{print $5, $9}')",
|
||||
"Bash(git add *)",
|
||||
"Bash(git commit -m ' *)",
|
||||
"Bash(git push *)",
|
||||
"Bash(cat .env.local)",
|
||||
"Bash(cat .env)",
|
||||
"Bash(vercel env *)",
|
||||
"Bash(python3 -m json.tool)",
|
||||
"Bash(xargs -0 '-I{}' bash -c 'name=$\\(basename \"{}\" .jsx\\); grep -q \"$name\" \"/Users/kraohasanee/Documents/40-49 Fourge Branding/41 Website/fourge-portal/src/App.jsx\" || echo \"UNLINKED: {}\"')",
|
||||
"mcp__plugin_supabase_supabase__execute_sql",
|
||||
"mcp__plugin_supabase_supabase__apply_migration",
|
||||
"Bash(git commit *)",
|
||||
"mcp__plugin_supabase_supabase__list_projects",
|
||||
"WebFetch(domain:github.com)",
|
||||
"Skill(supabase:supabase)",
|
||||
"mcp__plugin_everything-claude-code_playwright__browser_navigate",
|
||||
"mcp__plugin_everything-claude-code_exa__web_search_exa",
|
||||
"WebFetch(domain:filebrowserquantum.com)",
|
||||
"mcp__plugin_supabase_supabase__list_tables"
|
||||
"Bash(git commit -q -m ' *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -1,130 +1,5 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const FB_SOURCE = 'srv';
|
||||
|
||||
function normalizePath(path) {
|
||||
const raw = String(path || '/').trim();
|
||||
const parts = raw.split('/').filter(Boolean);
|
||||
const clean = [];
|
||||
for (const part of parts) {
|
||||
if (part === '.') continue;
|
||||
if (part === '..') throw new Error('Invalid path');
|
||||
clean.push(part);
|
||||
}
|
||||
return `/${clean.join('/')}`;
|
||||
}
|
||||
|
||||
function joinPath(...parts) {
|
||||
return normalizePath(parts.join('/'));
|
||||
}
|
||||
|
||||
function safeName(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function basename(path) {
|
||||
const parts = normalizePath(path).split('/').filter(Boolean);
|
||||
return parts[parts.length - 1] || '';
|
||||
}
|
||||
|
||||
function parentDir(path) {
|
||||
const parts = normalizePath(path).split('/').filter(Boolean);
|
||||
parts.pop();
|
||||
return `/${parts.join('/')}`;
|
||||
}
|
||||
|
||||
function getFbConfig() {
|
||||
const url = String(process.env.FILEBROWSER_URL || '').trim().replace(/\/+$/, '');
|
||||
return {
|
||||
url,
|
||||
token: process.env.FILEBROWSER_TOKEN || '',
|
||||
clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/Clients'),
|
||||
archiveRoot: normalizePath(process.env.FILEBROWSER_ARCHIVE_ROOT || '/Archive'),
|
||||
configured: Boolean(url) && Boolean(process.env.FILEBROWSER_TOKEN),
|
||||
};
|
||||
}
|
||||
|
||||
async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) {
|
||||
const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString();
|
||||
const res = await fetch(`${config.url}${endpoint}?${qs}`, {
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${config.token}`, ...headers },
|
||||
body,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
const err = new Error(text || `FileBrowser ${res.status}`);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
const text = await res.text();
|
||||
try { return text ? JSON.parse(text) : null; } catch { return text; }
|
||||
}
|
||||
|
||||
async function fbExists(config, path) {
|
||||
try { await fbFetch(config, 'GET', '/api/resources', { params: { path } }); return true; }
|
||||
catch (e) { if (e.status === 404) return false; throw e; }
|
||||
}
|
||||
|
||||
async function fbMkdir(config, path) {
|
||||
await fbFetch(config, 'POST', '/api/resources', { params: { path, isDir: 'true' } }).catch(() => {});
|
||||
}
|
||||
|
||||
async function mergeMove(config, fbSrc, fbDstParent) {
|
||||
const name = basename(fbSrc);
|
||||
const fbDst = joinPath(fbDstParent, name);
|
||||
const destExists = await fbExists(config, fbDst);
|
||||
if (!destExists) {
|
||||
await fbFetch(config, 'PATCH', '/api/resources', {
|
||||
params: {},
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'move',
|
||||
items: [{ fromSource: FB_SOURCE, fromPath: fbSrc, toSource: FB_SOURCE, toPath: fbDst }],
|
||||
overwrite: false,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
const data = await fbFetch(config, 'GET', '/api/resources', { params: { path: fbSrc } });
|
||||
const dirs = (data?.folders || []).map(f => f.name);
|
||||
const files = (data?.files || []).map(f => f.name);
|
||||
await fbMkdir(config, fbDst);
|
||||
for (const dir of dirs) await mergeMove(config, joinPath(fbSrc, dir), fbDst);
|
||||
for (const file of files) {
|
||||
await fbFetch(config, 'PATCH', '/api/resources', {
|
||||
params: {},
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'move',
|
||||
items: [{ fromSource: FB_SOURCE, fromPath: joinPath(fbSrc, file), toSource: FB_SOURCE, toPath: joinPath(fbDst, file) }],
|
||||
overwrite: true,
|
||||
}),
|
||||
}).catch(() => {});
|
||||
}
|
||||
await fbFetch(config, 'DELETE', '/api/resources', { params: { path: fbSrc } }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveProject(companyName, projectName) {
|
||||
const config = getFbConfig();
|
||||
if (!config.configured || !companyName || !projectName) return;
|
||||
const co = safeName(companyName);
|
||||
const proj = safeName(projectName);
|
||||
// Ensure archive dirs exist
|
||||
await fbMkdir(config, config.archiveRoot);
|
||||
await fbMkdir(config, joinPath(config.archiveRoot, 'Clients'));
|
||||
await fbMkdir(config, joinPath(config.archiveRoot, 'Clients', co));
|
||||
await fbMkdir(config, joinPath(config.archiveRoot, 'Clients', co, 'Projects'));
|
||||
// Merge-move project folder into archive
|
||||
const srcPath = joinPath(config.clientRoot, co, 'Projects', proj);
|
||||
const dstParentPath = joinPath(config.archiveRoot, 'Clients', co, 'Projects');
|
||||
await mergeMove(config, srcPath, dstParentPath);
|
||||
}
|
||||
|
||||
export default async function handler(req, res) {
|
||||
if (req.method !== 'DELETE') return res.status(405).json({ error: 'Method not allowed' });
|
||||
|
||||
@@ -152,22 +27,11 @@ export default async function handler(req, res) {
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
});
|
||||
|
||||
// Fetch project + company name before deletion (needed for archive path)
|
||||
const { data: projRecord } = await admin
|
||||
.from('projects')
|
||||
.select('id, name, company:companies(name)')
|
||||
.eq('id', projectId)
|
||||
.single();
|
||||
|
||||
if (!projRecord) return res.status(404).json({ error: 'Project not found' });
|
||||
|
||||
// For clients, confirm they can see this project (RLS gate)
|
||||
if (profile.role === 'client') {
|
||||
const { data: proj, error: projErr } = await callerClient.from('projects').select('id').eq('id', projectId).single();
|
||||
if (projErr || !proj) return res.status(404).json({ error: 'Project not found or access denied' });
|
||||
}
|
||||
|
||||
// Cleanup storage files
|
||||
const { data: tasks } = await admin.from('tasks').select('id').eq('project_id', projectId);
|
||||
const taskIds = (tasks || []).map(t => t.id);
|
||||
|
||||
@@ -190,17 +54,8 @@ export default async function handler(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// Delete project (DB cascade handles tasks/submissions/etc.)
|
||||
const { error } = await admin.from('projects').delete().eq('id', projectId);
|
||||
if (error) return res.status(500).json({ error: error.message });
|
||||
|
||||
// Archive FileBrowser project folder (server-side, so errors are logged)
|
||||
try {
|
||||
await archiveProject(projRecord.company?.name, projRecord.name);
|
||||
} catch (e) {
|
||||
console.error('[delete-project] archive failed:', e.message);
|
||||
// Don't fail — DB delete succeeded
|
||||
}
|
||||
|
||||
return res.status(200).json({ ok: true });
|
||||
}
|
||||
|
||||
+2
-140
@@ -1,126 +1,5 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const FB_SOURCE = 'srv';
|
||||
|
||||
function normalizePath(path) {
|
||||
const raw = String(path || '/').trim();
|
||||
const parts = raw.split('/').filter(Boolean);
|
||||
const clean = [];
|
||||
for (const part of parts) {
|
||||
if (part === '.') continue;
|
||||
if (part === '..') throw new Error('Invalid path');
|
||||
clean.push(part);
|
||||
}
|
||||
return `/${clean.join('/')}`;
|
||||
}
|
||||
|
||||
function joinPath(...parts) {
|
||||
return normalizePath(parts.join('/'));
|
||||
}
|
||||
|
||||
function safeName(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function basename(path) {
|
||||
const parts = normalizePath(path).split('/').filter(Boolean);
|
||||
return parts[parts.length - 1] || '';
|
||||
}
|
||||
|
||||
function getFbConfig() {
|
||||
const url = String(process.env.FILEBROWSER_URL || '').trim().replace(/\/+$/, '');
|
||||
return {
|
||||
url,
|
||||
token: process.env.FILEBROWSER_TOKEN || '',
|
||||
clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/Clients'),
|
||||
archiveRoot: normalizePath(process.env.FILEBROWSER_ARCHIVE_ROOT || '/Archive'),
|
||||
configured: Boolean(url) && Boolean(process.env.FILEBROWSER_TOKEN),
|
||||
};
|
||||
}
|
||||
|
||||
async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) {
|
||||
const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString();
|
||||
const res = await fetch(`${config.url}${endpoint}?${qs}`, {
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${config.token}`, ...headers },
|
||||
body,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
const err = new Error(text || `FileBrowser ${res.status}`);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
const text = await res.text();
|
||||
try { return text ? JSON.parse(text) : null; } catch { return text; }
|
||||
}
|
||||
|
||||
async function fbExists(config, path) {
|
||||
try { await fbFetch(config, 'GET', '/api/resources', { params: { path } }); return true; }
|
||||
catch (e) { if (e.status === 404) return false; throw e; }
|
||||
}
|
||||
|
||||
async function fbMkdir(config, path) {
|
||||
await fbFetch(config, 'POST', '/api/resources', { params: { path, isDir: 'true' } }).catch(() => {});
|
||||
}
|
||||
|
||||
async function mergeMove(config, fbSrc, fbDstParent) {
|
||||
const name = basename(fbSrc);
|
||||
const fbDst = joinPath(fbDstParent, name);
|
||||
const destExists = await fbExists(config, fbDst);
|
||||
if (!destExists) {
|
||||
await fbFetch(config, 'PATCH', '/api/resources', {
|
||||
params: {},
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'move',
|
||||
items: [{ fromSource: FB_SOURCE, fromPath: fbSrc, toSource: FB_SOURCE, toPath: fbDst }],
|
||||
overwrite: false,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
const data = await fbFetch(config, 'GET', '/api/resources', { params: { path: fbSrc } });
|
||||
const dirs = (data?.folders || []).map(f => f.name);
|
||||
const files = (data?.files || []).map(f => f.name);
|
||||
await fbMkdir(config, fbDst);
|
||||
for (const dir of dirs) await mergeMove(config, joinPath(fbSrc, dir), fbDst);
|
||||
for (const file of files) {
|
||||
await fbFetch(config, 'PATCH', '/api/resources', {
|
||||
params: {},
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'move',
|
||||
items: [{ fromSource: FB_SOURCE, fromPath: joinPath(fbSrc, file), toSource: FB_SOURCE, toPath: joinPath(fbDst, file) }],
|
||||
overwrite: true,
|
||||
}),
|
||||
}).catch(() => {});
|
||||
}
|
||||
await fbFetch(config, 'DELETE', '/api/resources', { params: { path: fbSrc } }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveTask(companyName, projectName, taskTitle) {
|
||||
const config = getFbConfig();
|
||||
if (!config.configured || !companyName || !projectName || !taskTitle) return;
|
||||
const co = safeName(companyName);
|
||||
const proj = safeName(projectName);
|
||||
const task = safeName(taskTitle);
|
||||
// Ensure archive dirs exist
|
||||
await fbMkdir(config, config.archiveRoot);
|
||||
await fbMkdir(config, joinPath(config.archiveRoot, 'Clients'));
|
||||
await fbMkdir(config, joinPath(config.archiveRoot, 'Clients', co));
|
||||
await fbMkdir(config, joinPath(config.archiveRoot, 'Clients', co, 'Projects'));
|
||||
await fbMkdir(config, joinPath(config.archiveRoot, 'Clients', co, 'Projects', proj));
|
||||
// Merge-move task folder into archive
|
||||
const srcPath = joinPath(config.clientRoot, co, 'Projects', proj, task);
|
||||
const dstParentPath = joinPath(config.archiveRoot, 'Clients', co, 'Projects', proj);
|
||||
await mergeMove(config, srcPath, dstParentPath);
|
||||
}
|
||||
|
||||
export default async function handler(req, res) {
|
||||
if (req.method !== 'DELETE') return res.status(405).json({ error: 'Method not allowed' });
|
||||
|
||||
@@ -148,16 +27,9 @@ export default async function handler(req, res) {
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
});
|
||||
|
||||
// Fetch task + project + company before deletion using explicit joins
|
||||
const { data: taskRecord } = await admin.from('tasks').select('id, title, project_id').eq('id', taskId).single();
|
||||
const { data: taskRecord } = await admin.from('tasks').select('id').eq('id', taskId).single();
|
||||
if (!taskRecord) return res.status(404).json({ error: 'Task not found' });
|
||||
|
||||
const { data: projectRecord } = await admin.from('projects').select('id, name, company_id').eq('id', taskRecord.project_id).single();
|
||||
const { data: companyRecord } = projectRecord?.company_id
|
||||
? await admin.from('companies').select('id, name').eq('id', projectRecord.company_id).single()
|
||||
: { data: null };
|
||||
|
||||
// Cleanup storage files
|
||||
const { data: subs } = await admin.from('submissions').select('id').eq('task_id', taskId);
|
||||
const subIds = (subs || []).map(s => s.id);
|
||||
|
||||
@@ -175,18 +47,8 @@ export default async function handler(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// Delete task (DB cascade handles submissions/etc.)
|
||||
const { error } = await admin.from('tasks').delete().eq('id', taskId);
|
||||
if (error) return res.status(500).json({ error: error.message });
|
||||
|
||||
// Archive FileBrowser task folder
|
||||
let archiveError = null;
|
||||
try {
|
||||
await archiveTask(companyRecord?.name, projectRecord?.name, taskRecord.title);
|
||||
} catch (e) {
|
||||
archiveError = e.message;
|
||||
console.error('[delete-task] archive failed:', e.message);
|
||||
}
|
||||
|
||||
return res.status(200).json({ ok: true, archiveError });
|
||||
return res.status(200).json({ ok: true });
|
||||
}
|
||||
|
||||
+292
-504
@@ -1,6 +1,10 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const FB_SOURCE = 'srv';
|
||||
const PROJECT_DEFAULT_SUBFOLDERS = ['00 Project Files'];
|
||||
const REQUEST_DEFAULT_SUBFOLDERS = ['Old Books', 'Working Files', 'Survey'];
|
||||
|
||||
export const config = { api: { bodyParser: false, sizeLimit: '50mb' } };
|
||||
|
||||
function json(res, status, body) {
|
||||
res.status(status).setHeader('Content-Type', 'application/json');
|
||||
@@ -24,17 +28,6 @@ function joinPath(...parts) {
|
||||
return normalizePath(parts.join('/'));
|
||||
}
|
||||
|
||||
function parentDir(path) {
|
||||
const parts = normalizePath(path).split('/').filter(Boolean);
|
||||
parts.pop();
|
||||
return `/${parts.join('/')}`;
|
||||
}
|
||||
|
||||
function basename(path) {
|
||||
const parts = normalizePath(path).split('/').filter(Boolean);
|
||||
return parts[parts.length - 1] || '';
|
||||
}
|
||||
|
||||
function safeName(value, fallback = '') {
|
||||
const cleaned = String(value || '')
|
||||
.trim()
|
||||
@@ -44,146 +37,67 @@ function safeName(value, fallback = '') {
|
||||
return cleaned || fallback;
|
||||
}
|
||||
|
||||
function parseBody(body) {
|
||||
if (!body) return {};
|
||||
if (typeof body === 'string') {
|
||||
try {
|
||||
return JSON.parse(body);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
async function readBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
req.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
|
||||
req.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function getConfig() {
|
||||
const url = String(process.env.FILEBROWSER_URL || '').trim().replace(/\/+$/, '');
|
||||
return {
|
||||
url,
|
||||
token: process.env.FILEBROWSER_TOKEN || '',
|
||||
adminUser: process.env.FILEBROWSER_ADMIN_USER || '',
|
||||
adminPass: process.env.FILEBROWSER_ADMIN_PASS || '',
|
||||
teamRoot: normalizePath(process.env.FILEBROWSER_TEAM_ROOT || '/'),
|
||||
clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients'),
|
||||
externalSubsRoot: normalizePath(process.env.FILEBROWSER_SUBS_ROOT || '/fourgebranding/Subcontractors'),
|
||||
externalClientsRoot: normalizePath(process.env.FILEBROWSER_CLIENTS_ROOT || '/fourgebranding/Clients'),
|
||||
subsRoot: normalizePath(process.env.FILEBROWSER_SUBS_ROOT || '/fourgebranding/team'),
|
||||
configured: Boolean(url),
|
||||
};
|
||||
}
|
||||
|
||||
let runtimeToken = '';
|
||||
let runtimeTokenTs = 0;
|
||||
const RUNTIME_TOKEN_TTL_MS = 30 * 60 * 1000;
|
||||
|
||||
function extractLoginToken(payload) {
|
||||
if (typeof payload === 'string') {
|
||||
const raw = payload.trim();
|
||||
if (raw.split('.').length === 3) return raw;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return extractLoginToken(parsed);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
if (!payload || typeof payload !== 'object') return '';
|
||||
return String(
|
||||
payload.token
|
||||
|| payload.auth
|
||||
|| payload.access_token
|
||||
|| payload.jwt
|
||||
|| payload?.data?.token
|
||||
|| ''
|
||||
).trim();
|
||||
}
|
||||
|
||||
async function loginForToken(config) {
|
||||
if (!config.url || !config.adminUser || !config.adminPass) return '';
|
||||
const endpoints = ['/api/auth/login', '/api/login'];
|
||||
let lastError = '';
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
try {
|
||||
const response = await fetch(`${config.url}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: config.adminUser, password: config.adminPass }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
// Some FileBrowser deployments only expose /api/auth/login.
|
||||
// Ignore /api/login 404 and keep trying/using prior success signal.
|
||||
if (endpoint === '/api/login' && response.status === 404) continue;
|
||||
lastError = `${endpoint} -> ${response.status} ${text?.slice(0, 180) || ''}`.trim();
|
||||
continue;
|
||||
}
|
||||
const bodyText = await response.text();
|
||||
const payload = (() => {
|
||||
try {
|
||||
return JSON.parse(bodyText);
|
||||
} catch {
|
||||
return bodyText;
|
||||
}
|
||||
})();
|
||||
const token = extractLoginToken(payload);
|
||||
if (token) return token;
|
||||
lastError = `${endpoint} -> 200 but token missing`;
|
||||
} catch {
|
||||
lastError = `${endpoint} -> request failed`;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastError) {
|
||||
const err = new Error(`FileBrowser login failed: ${lastError}`);
|
||||
err.status = 401;
|
||||
throw err;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
async function getToken(config, forceRefresh = false) {
|
||||
const now = Date.now();
|
||||
if (!forceRefresh && config.token && String(config.token).trim()) {
|
||||
return String(config.token).trim();
|
||||
}
|
||||
if (!forceRefresh && runtimeToken && (now - runtimeTokenTs) < RUNTIME_TOKEN_TTL_MS) {
|
||||
return runtimeToken;
|
||||
}
|
||||
|
||||
if (!forceRefresh) {
|
||||
const freshPreferred = await loginForToken(config);
|
||||
if (freshPreferred) {
|
||||
runtimeToken = freshPreferred;
|
||||
runtimeTokenTs = now;
|
||||
return runtimeToken;
|
||||
}
|
||||
}
|
||||
|
||||
const fresh = await loginForToken(config);
|
||||
if (!fresh) throw new Error('FileBrowser login returned no token.');
|
||||
runtimeToken = fresh;
|
||||
runtimeTokenTs = now;
|
||||
return runtimeToken;
|
||||
function getToken(config) {
|
||||
const token = String(config.token || '').trim();
|
||||
if (!token) throw new Error('FILEBROWSER_TOKEN not configured');
|
||||
return token;
|
||||
}
|
||||
|
||||
async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) {
|
||||
const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString();
|
||||
const url = `${config.url}${endpoint}?${qs}`;
|
||||
const token = getToken(config);
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${token}`, ...headers },
|
||||
body,
|
||||
});
|
||||
|
||||
async function callWith(token) {
|
||||
return fetch(url, {
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${token}`, ...headers },
|
||||
body,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
const error = new Error(text || `FileBrowser ${response.status}`);
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
let token = await getToken(config);
|
||||
let res = await callWith(token);
|
||||
|
||||
if (res.status === 401) {
|
||||
// Always force-refresh and retry once on Unauthorized.
|
||||
token = await getToken(config, true);
|
||||
res = await callWith(token);
|
||||
const text = await response.text();
|
||||
try {
|
||||
return text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
const err = new Error(text || `FileBrowser ${res.status}`);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
try { return text ? JSON.parse(text) : null; } catch { return text; }
|
||||
}
|
||||
|
||||
async function createCallerClient(authHeader) {
|
||||
@@ -203,17 +117,16 @@ async function requirePortalUser(authHeader) {
|
||||
|
||||
const { data: profile, error: profileError } = await callerClient
|
||||
.from('profiles')
|
||||
.select('id, name, role, company:companies(id, name)')
|
||||
.select('id, role, company:companies(id, name)')
|
||||
.eq('id', userData.user.id)
|
||||
.single();
|
||||
|
||||
if (profileError) return { ok: false, status: 500, message: profileError.message };
|
||||
if (!['team', 'external', 'client'].includes(profile?.role)) {
|
||||
if (!['team', 'client'].includes(profile?.role)) {
|
||||
return { ok: false, status: 403, message: 'Forbidden' };
|
||||
}
|
||||
|
||||
// Mirror AuthContext: load all companies (FK + company_members)
|
||||
let clientCompanies = [];
|
||||
const clientCompanies = [];
|
||||
if (profile.role === 'client') {
|
||||
const seen = new Set();
|
||||
if (profile.company?.id) {
|
||||
@@ -224,400 +137,275 @@ async function requirePortalUser(authHeader) {
|
||||
.from('company_members')
|
||||
.select('company:companies(id, name)')
|
||||
.eq('profile_id', userData.user.id);
|
||||
for (const m of memberships || []) {
|
||||
if (m.company?.id && !seen.has(m.company.id)) {
|
||||
clientCompanies.push(m.company);
|
||||
seen.add(m.company.id);
|
||||
for (const row of memberships || []) {
|
||||
if (row.company?.id && !seen.has(row.company.id)) {
|
||||
seen.add(row.company.id);
|
||||
clientCompanies.push(row.company);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, callerClient, profile: { ...profile, clientCompanies, email: userData.user.email } };
|
||||
}
|
||||
|
||||
async function getExternalProjects(callerClient, userId) {
|
||||
const { data, error } = await callerClient
|
||||
.from('project_members')
|
||||
.select('project:projects(id, name, company:companies(name))')
|
||||
.eq('profile_id', userId);
|
||||
|
||||
if (error) throw new Error(error.message);
|
||||
return (data || []).map(r => r.project).filter(Boolean);
|
||||
}
|
||||
|
||||
function resolveUserRoot(config, profile) {
|
||||
if (profile.role === 'team') return config.teamRoot;
|
||||
if (profile.role === 'client') {
|
||||
const companyFolder = safeName(profile.company?.name, profile.id);
|
||||
return joinPath(config.clientRoot, companyFolder);
|
||||
}
|
||||
if (profile.role === 'external') return config.externalSubsRoot;
|
||||
return '/';
|
||||
}
|
||||
|
||||
function resolveClientPath(config, vPath, companies) {
|
||||
const parts = normalizePath(vPath).split('/').filter(Boolean);
|
||||
if (parts.length === 0) return { virtual: true };
|
||||
|
||||
const companyFolder = parts[0];
|
||||
const match = companies.find(c => safeName(c.name, '') === companyFolder);
|
||||
if (!match) {
|
||||
const err = new Error('Access denied');
|
||||
err.status = 403;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const rest = parts.slice(1);
|
||||
const base = joinPath(config.clientRoot, companyFolder);
|
||||
const fbPath = rest.length > 0 ? joinPath(base, ...rest) : base;
|
||||
return { virtual: false, fbPath };
|
||||
}
|
||||
|
||||
function buildClientVirtualEntries(companies) {
|
||||
return companies.map(c => {
|
||||
const name = safeName(c.name, c.id);
|
||||
return { name, type: 'dir', size: 0, mtime: null, path: `/${name}` };
|
||||
});
|
||||
}
|
||||
|
||||
function resolveExternalPath(config, vPath, profile, projects) {
|
||||
const myFolder = safeName(profile.name, profile.id);
|
||||
const parts = normalizePath(vPath).split('/').filter(Boolean);
|
||||
|
||||
if (parts.length === 0) return { virtual: true };
|
||||
|
||||
// Their personal Team folder
|
||||
if (parts[0] === myFolder) {
|
||||
const fbPath = joinPath(config.externalSubsRoot, ...parts);
|
||||
return { virtual: false, fbPath };
|
||||
}
|
||||
|
||||
// Assigned client projects — flattened: Projects/{project}/...
|
||||
if (parts[0] === 'Projects') {
|
||||
if (parts.length < 2) return { virtual: true };
|
||||
const [, projectFolder, ...rest] = parts;
|
||||
const match = projects.find(p => safeName(p.name, '') === projectFolder);
|
||||
if (!match) {
|
||||
const err = new Error('Access denied to this project');
|
||||
err.status = 403;
|
||||
throw err;
|
||||
}
|
||||
const company = safeName(match.company?.name, '');
|
||||
const base = joinPath(config.externalClientsRoot, company, 'Projects', projectFolder);
|
||||
const fbPath = rest.length > 0 ? joinPath(base, ...rest) : base;
|
||||
return { virtual: false, fbPath };
|
||||
}
|
||||
|
||||
const err = new Error('Access denied');
|
||||
err.status = 403;
|
||||
throw err;
|
||||
}
|
||||
|
||||
function buildExternalVirtualEntries(vPath, profile, projects) {
|
||||
const myFolder = safeName(profile.name, profile.id);
|
||||
const parts = normalizePath(vPath).split('/').filter(Boolean);
|
||||
|
||||
if (parts.length === 0) {
|
||||
const entries = [{ name: myFolder, type: 'dir', size: 0, mtime: null, path: `/${myFolder}` }];
|
||||
if (projects.length > 0) entries.push({ name: 'Projects', type: 'dir', size: 0, mtime: null, path: '/Projects' });
|
||||
return entries;
|
||||
}
|
||||
|
||||
if (parts[0] === 'Projects' && parts.length === 1) {
|
||||
const seen = new Set();
|
||||
return projects
|
||||
.map(p => safeName(p.name, ''))
|
||||
.filter(name => name && !seen.has(name) && seen.add(name))
|
||||
.map(name => ({ name, type: 'dir', size: 0, mtime: null, path: `/Projects/${name}` }));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeQuantumItems(data, virtualPath) {
|
||||
const dirs = (data?.folders || []).map(item => ({ ...item, _type: 'directory' }));
|
||||
const files = (data?.files || []).map(item => ({ ...item, _type: item.type || 'file' }));
|
||||
const items = [...dirs, ...files].map(item => ({
|
||||
name: item.name,
|
||||
type: (item._type === 'directory' || item.type === 'directory') ? 'dir' : 'file',
|
||||
size: (item._type === 'directory' || item.type === 'directory') ? 0 : (item.size || 0),
|
||||
mtime: item.modified || null,
|
||||
path: joinPath(virtualPath, item.name),
|
||||
}));
|
||||
return items.sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
|
||||
if (a.name === '00 Project Files') return -1;
|
||||
if (b.name === '00 Project Files') return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
function toListResponse(vPath, entries, { readOnly = false } = {}) {
|
||||
return {
|
||||
configured: true,
|
||||
path: vPath,
|
||||
canGoUp: vPath !== '/',
|
||||
parentPath: parentDir(vPath),
|
||||
entries,
|
||||
readOnly,
|
||||
ok: true,
|
||||
profile: { ...profile, clientCompanies },
|
||||
};
|
||||
}
|
||||
|
||||
export const config = { api: { bodyParser: false, sizeLimit: '50mb' } };
|
||||
|
||||
async function readBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
req.on('data', chunk => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
|
||||
req.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
req.on('error', reject);
|
||||
function createAdminClient() {
|
||||
const supabaseUrl = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
if (!supabaseUrl || !serviceKey) throw new Error('Supabase admin env not configured');
|
||||
return createClient(supabaseUrl, serviceKey, {
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
});
|
||||
}
|
||||
|
||||
function isAlreadyExistsError(error) {
|
||||
const message = String(error?.message || '').toLowerCase();
|
||||
return error?.status === 409 || message.includes('exist') || message.includes('conflict');
|
||||
}
|
||||
|
||||
async function ensureDirectory(config, fullPath) {
|
||||
const parts = normalizePath(fullPath).split('/').filter(Boolean);
|
||||
let current = '/';
|
||||
for (const part of parts) {
|
||||
current = joinPath(current, part);
|
||||
try {
|
||||
await fbFetch(config, 'POST', '/api/resources', {
|
||||
params: { path: current, isDir: 'true' },
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isAlreadyExistsError(error)) throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function renameDirectory(config, fromPath, toPath) {
|
||||
const sourcePath = normalizePath(fromPath);
|
||||
const targetPath = normalizePath(toPath);
|
||||
if (sourcePath === targetPath) return { renamed: false, skipped: true, path: targetPath };
|
||||
|
||||
try {
|
||||
await fbFetch(config, 'PATCH', '/api/resources', {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'rename',
|
||||
items: [{ fromSource: FB_SOURCE, fromPath: sourcePath, toSource: FB_SOURCE, toPath: targetPath }],
|
||||
overwrite: false,
|
||||
}),
|
||||
});
|
||||
return { renamed: true, path: targetPath };
|
||||
} catch (error) {
|
||||
if (error?.status === 404) {
|
||||
await ensureDirectory(config, targetPath);
|
||||
return { renamed: false, ensured: true, path: targetPath };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export default async function handler(req, res) {
|
||||
const action = String(req.query?.action || '').trim();
|
||||
if (req.method !== 'POST' || ![
|
||||
'ensure-company-folder',
|
||||
'ensure-project-folder',
|
||||
'ensure-request-folder',
|
||||
'ensure-profile-folder',
|
||||
'upload-request-file',
|
||||
'rename-company-folder',
|
||||
'rename-project-folder',
|
||||
'rename-profile-folder',
|
||||
].includes(action)) {
|
||||
return json(res, 405, { error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
const authHeader = req.headers.authorization || '';
|
||||
if (!authHeader.startsWith('Bearer ')) {
|
||||
return json(res, 401, { error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const config = getConfig();
|
||||
if (!config.configured) {
|
||||
return json(res, 200, { ok: false, configured: false, warning: 'FileBrowser is not configured.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const rawBody = await readBody(req);
|
||||
const body = (() => {
|
||||
if (!rawBody.length) return {};
|
||||
const ct = (req.headers['content-type'] || '').split(';')[0].trim();
|
||||
if (ct === 'application/json') { try { return JSON.parse(rawBody.toString()); } catch { return {}; } }
|
||||
const contentType = String(req.headers['content-type'] || '').split(';')[0].trim();
|
||||
if (contentType === 'application/json') {
|
||||
try {
|
||||
return JSON.parse(rawBody.toString());
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
})();
|
||||
|
||||
const queryToken = String(req.query?.sb_access_token || body?.sb_access_token || '').trim();
|
||||
const authHeader = req.headers.authorization || (queryToken ? `Bearer ${queryToken}` : '');
|
||||
if (!authHeader) return json(res, 401, { error: 'No authorization header' });
|
||||
|
||||
const auth = await requirePortalUser(authHeader);
|
||||
if (!auth.ok) return json(res, auth.status, { error: auth.message });
|
||||
|
||||
const config = getConfig();
|
||||
if (!config.configured || !config.token) {
|
||||
return json(res, 200, {
|
||||
configured: false,
|
||||
error: 'FileBrowser not configured.',
|
||||
requiredEnv: ['FILEBROWSER_URL', 'FILEBROWSER_TOKEN'],
|
||||
const admin = createAdminClient();
|
||||
if (action === 'ensure-company-folder') {
|
||||
if (auth.profile.role !== 'team') {
|
||||
return json(res, 403, { error: 'Forbidden' });
|
||||
}
|
||||
const companyId = String(body.companyId || '').trim();
|
||||
if (!companyId) return json(res, 400, { error: 'companyId is required.' });
|
||||
|
||||
const { data: company, error: companyError } = await admin
|
||||
.from('companies')
|
||||
.select('id, name')
|
||||
.eq('id', companyId)
|
||||
.single();
|
||||
if (companyError || !company) return json(res, 404, { error: 'Company not found.' });
|
||||
|
||||
const fullPath = joinPath(config.clientRoot, safeName(company.name, company.id));
|
||||
await ensureDirectory(config, fullPath);
|
||||
return json(res, 200, { ok: true, configured: true, path: fullPath });
|
||||
}
|
||||
|
||||
if (action === 'rename-company-folder') {
|
||||
if (auth.profile.role !== 'team') {
|
||||
return json(res, 403, { error: 'Forbidden' });
|
||||
}
|
||||
const companyId = String(body.companyId || '').trim();
|
||||
const oldName = safeName(body.oldName, '');
|
||||
const newName = safeName(body.newName, '');
|
||||
if (!companyId || !oldName || !newName) return json(res, 400, { error: 'companyId, oldName, and newName are required.' });
|
||||
|
||||
const { data: company, error: companyError } = await admin
|
||||
.from('companies')
|
||||
.select('id')
|
||||
.eq('id', companyId)
|
||||
.single();
|
||||
if (companyError || !company) return json(res, 404, { error: 'Company not found.' });
|
||||
|
||||
const result = await renameDirectory(config, joinPath(config.clientRoot, oldName), joinPath(config.clientRoot, newName));
|
||||
return json(res, 200, { ok: true, configured: true, ...result });
|
||||
}
|
||||
|
||||
if (action === 'ensure-profile-folder') {
|
||||
if (auth.profile.role !== 'team') {
|
||||
return json(res, 403, { error: 'Forbidden' });
|
||||
}
|
||||
const profileId = String(body.profileId || '').trim();
|
||||
if (!profileId) return json(res, 400, { error: 'profileId is required.' });
|
||||
|
||||
const { data: profile, error: profileError } = await admin
|
||||
.from('profiles')
|
||||
.select('id, name, role')
|
||||
.eq('id', profileId)
|
||||
.single();
|
||||
if (profileError || !profile) return json(res, 404, { error: 'Profile not found.' });
|
||||
if (profile.role !== 'external') return json(res, 400, { error: 'Only subcontractor folders are supported here.' });
|
||||
|
||||
const fullPath = joinPath(config.subsRoot, safeName(profile.name, profile.id));
|
||||
await ensureDirectory(config, fullPath);
|
||||
return json(res, 200, { ok: true, configured: true, path: fullPath });
|
||||
}
|
||||
|
||||
if (action === 'rename-profile-folder') {
|
||||
if (auth.profile.role !== 'team') {
|
||||
return json(res, 403, { error: 'Forbidden' });
|
||||
}
|
||||
const profileId = String(body.profileId || '').trim();
|
||||
const oldName = safeName(body.oldName, '');
|
||||
const newName = safeName(body.newName, '');
|
||||
if (!profileId || !oldName || !newName) return json(res, 400, { error: 'profileId, oldName, and newName are required.' });
|
||||
|
||||
const { data: profile, error: profileError } = await admin
|
||||
.from('profiles')
|
||||
.select('id, role')
|
||||
.eq('id', profileId)
|
||||
.single();
|
||||
if (profileError || !profile) return json(res, 404, { error: 'Profile not found.' });
|
||||
if (profile.role !== 'external') return json(res, 400, { error: 'Only subcontractor folders are supported here.' });
|
||||
|
||||
const result = await renameDirectory(config, joinPath(config.subsRoot, oldName), joinPath(config.subsRoot, newName));
|
||||
return json(res, 200, { ok: true, configured: true, ...result });
|
||||
}
|
||||
|
||||
const projectId = String(req.query?.projectId || body.projectId || '').trim();
|
||||
if (!projectId) return json(res, 400, { error: 'projectId is required.' });
|
||||
|
||||
const { data: project, error: projectError } = await admin
|
||||
.from('projects')
|
||||
.select('id, name, company_id, company:companies(id, name)')
|
||||
.eq('id', projectId)
|
||||
.single();
|
||||
if (projectError || !project) return json(res, 404, { error: 'Project not found.' });
|
||||
|
||||
if (auth.profile.role === 'client') {
|
||||
const allowedCompanyIds = new Set((auth.profile.clientCompanies || []).map(company => company.id));
|
||||
if (!allowedCompanyIds.has(project.company_id)) return json(res, 403, { error: 'Forbidden' });
|
||||
}
|
||||
|
||||
const companyName = safeName(project.company?.name, String(project.company_id || 'company'));
|
||||
const projectName = safeName(project.name, projectId);
|
||||
|
||||
if (action === 'rename-project-folder') {
|
||||
const oldName = safeName(body.oldName, '');
|
||||
const newName = safeName(body.newName, '');
|
||||
if (!oldName || !newName) return json(res, 400, { error: 'oldName and newName are required.' });
|
||||
const result = await renameDirectory(
|
||||
config,
|
||||
joinPath(config.clientRoot, companyName, 'Projects', oldName),
|
||||
joinPath(config.clientRoot, companyName, 'Projects', newName)
|
||||
);
|
||||
return json(res, 200, { ok: true, configured: true, ...result });
|
||||
}
|
||||
|
||||
if (action === 'ensure-project-folder') {
|
||||
const fullPath = joinPath(config.clientRoot, companyName, 'Projects', projectName);
|
||||
await ensureDirectory(config, fullPath);
|
||||
for (const folderName of PROJECT_DEFAULT_SUBFOLDERS) {
|
||||
await ensureDirectory(config, joinPath(fullPath, folderName));
|
||||
}
|
||||
return json(res, 200, { ok: true, configured: true, path: fullPath });
|
||||
}
|
||||
|
||||
const taskTitle = safeName(req.query?.taskTitle || body.taskTitle, '');
|
||||
if (!taskTitle) return json(res, 400, { error: 'taskTitle is required.' });
|
||||
const fullPath = joinPath(config.clientRoot, companyName, 'Projects', projectName, taskTitle);
|
||||
|
||||
if (action === 'upload-request-file') {
|
||||
const bucket = String(body.bucket || 'submissions').trim();
|
||||
const storagePath = String(body.storagePath || '').trim();
|
||||
const fileName = safeName(body.fileName || req.query?.fileName || req.headers['x-file-name'], '');
|
||||
if (!storagePath) return json(res, 400, { error: 'storagePath is required.' });
|
||||
if (!fileName) return json(res, 400, { error: 'fileName is required.' });
|
||||
const surveyPath = joinPath(fullPath, 'Survey');
|
||||
await ensureDirectory(config, surveyPath);
|
||||
const { data: downloadedFile, error: downloadError } = await admin.storage.from(bucket).download(storagePath);
|
||||
if (downloadError || !downloadedFile) {
|
||||
return json(res, 500, { error: downloadError?.message || 'Failed to download request file from storage.' });
|
||||
}
|
||||
const fileBuffer = Buffer.from(await downloadedFile.arrayBuffer());
|
||||
const uploadUrl = `${config.url}/api/resources?source=${FB_SOURCE}&path=${encodeURIComponent(joinPath(surveyPath, fileName))}&override=true`;
|
||||
const contentType = String(downloadedFile.type || '').trim() || 'application/octet-stream';
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${getToken(config)}`,
|
||||
'Content-Type': contentType,
|
||||
},
|
||||
body: fileBuffer,
|
||||
});
|
||||
}
|
||||
|
||||
const action = req.query.action || (req.method === 'GET' ? 'list' : '');
|
||||
const requestedPath = req.query.path || body?.path || '/';
|
||||
|
||||
let externalProjects = [];
|
||||
if (auth.profile.role === 'external') {
|
||||
externalProjects = await getExternalProjects(auth.callerClient, auth.profile.id);
|
||||
}
|
||||
|
||||
function toFbPath(vPath = requestedPath) {
|
||||
if (auth.profile.role === 'external') {
|
||||
return resolveExternalPath(config, normalizePath(vPath), auth.profile, externalProjects);
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return json(res, response.status, { error: text || `Upload failed (${response.status})` });
|
||||
}
|
||||
if (auth.profile.role === 'client') {
|
||||
return resolveClientPath(config, normalizePath(vPath), auth.profile.clientCompanies);
|
||||
}
|
||||
const root = resolveUserRoot(config, auth.profile);
|
||||
return { virtual: false, fbPath: joinPath(root, normalizePath(vPath)) };
|
||||
return json(res, 200, { ok: true, configured: true, path: joinPath(surveyPath, fileName) });
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && action === 'config') {
|
||||
return json(res, 200, { configured: true, role: auth.profile.role, url: config.url });
|
||||
await ensureDirectory(config, fullPath);
|
||||
for (const folderName of REQUEST_DEFAULT_SUBFOLDERS) {
|
||||
await ensureDirectory(config, joinPath(fullPath, folderName));
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && action === 'list') {
|
||||
const vPath = normalizePath(requestedPath);
|
||||
if (auth.profile.role === 'external') {
|
||||
const resolved = resolveExternalPath(config, vPath, auth.profile, externalProjects);
|
||||
if (resolved.virtual) {
|
||||
return json(res, 200, toListResponse(vPath, buildExternalVirtualEntries(vPath, auth.profile, externalProjects), { readOnly: true }));
|
||||
}
|
||||
const data = await fbFetch(config, 'GET', '/api/resources', { params: { path: resolved.fbPath } });
|
||||
return json(res, 200, toListResponse(vPath, normalizeQuantumItems(data, vPath)));
|
||||
}
|
||||
if (auth.profile.role === 'client') {
|
||||
const resolved = resolveClientPath(config, vPath, auth.profile.clientCompanies);
|
||||
if (resolved.virtual) {
|
||||
return json(res, 200, toListResponse(vPath, buildClientVirtualEntries(auth.profile.clientCompanies)));
|
||||
}
|
||||
const data = await fbFetch(config, 'GET', '/api/resources', { params: { path: resolved.fbPath } });
|
||||
return json(res, 200, toListResponse(vPath, normalizeQuantumItems(data, vPath)));
|
||||
}
|
||||
const root = resolveUserRoot(config, auth.profile);
|
||||
const fbPath = joinPath(root, vPath);
|
||||
const data = await fbFetch(config, 'GET', '/api/resources', { params: { path: fbPath } });
|
||||
return json(res, 200, toListResponse(vPath, normalizeQuantumItems(data, vPath)));
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && action === 'download') {
|
||||
const resolved = toFbPath();
|
||||
if (resolved.virtual) return json(res, 400, { error: 'Cannot download virtual directory' });
|
||||
const token = await getToken(config);
|
||||
const downloadUrl = `${config.url}/api/resources/download?source=${FB_SOURCE}&file=${encodeURIComponent(resolved.fbPath)}`;
|
||||
return json(res, 200, { url: downloadUrl, token });
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && action === 'download-blob') {
|
||||
const resolved = toFbPath();
|
||||
if (resolved.virtual) return json(res, 400, { error: 'Cannot download virtual directory' });
|
||||
const token = await getToken(config);
|
||||
const downloadUrl = `${config.url}/api/resources/download?source=${FB_SOURCE}&file=${encodeURIComponent(resolved.fbPath)}&auth=${encodeURIComponent(token)}`;
|
||||
let upstream = await fetch(downloadUrl);
|
||||
if (upstream.status === 401) {
|
||||
const fresh = await getToken(config, true);
|
||||
const retryUrl = `${config.url}/api/resources/download?source=${FB_SOURCE}&file=${encodeURIComponent(resolved.fbPath)}&auth=${encodeURIComponent(fresh)}`;
|
||||
upstream = await fetch(retryUrl);
|
||||
}
|
||||
if (!upstream.ok) {
|
||||
const text = await upstream.text();
|
||||
return json(res, upstream.status, { error: text || `Download failed (${upstream.status})` });
|
||||
}
|
||||
|
||||
res.status(upstream.status);
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.setHeader('Content-Type', upstream.headers.get('content-type') || 'application/octet-stream');
|
||||
res.setHeader('Content-Disposition', upstream.headers.get('content-disposition') || `attachment; filename="${basename(resolved.fbPath) || 'download'}"`);
|
||||
const arrayBuffer = await upstream.arrayBuffer();
|
||||
return res.send(Buffer.from(arrayBuffer));
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && action === 'upload-token') {
|
||||
const resolved = toFbPath();
|
||||
if (resolved.virtual) return json(res, 400, { error: 'Cannot upload to virtual directory' });
|
||||
const token = await getToken(config);
|
||||
return json(res, 200, { token, url: config.url, fbPath: resolved.fbPath });
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && action === 'upload') {
|
||||
const resolved = toFbPath();
|
||||
if (resolved.virtual) return json(res, 400, { error: 'Cannot upload to virtual directory' });
|
||||
const contentType = (req.headers['content-type'] || 'application/octet-stream').split(';')[0].trim();
|
||||
const uploadUrl = `${config.url}/api/resources?source=${FB_SOURCE}&path=${encodeURIComponent(resolved.fbPath)}&override=true`;
|
||||
let token = await getToken(config);
|
||||
let upRes = await fetch(uploadUrl, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': contentType }, body: rawBody });
|
||||
if (upRes.status === 401) {
|
||||
token = await getToken(config, true);
|
||||
upRes = await fetch(uploadUrl, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': contentType }, body: rawBody });
|
||||
}
|
||||
if (!upRes.ok) { const t = await upRes.text(); return json(res, upRes.status, { error: t || `Upload failed (${upRes.status})` }); }
|
||||
return json(res, 200, { success: true });
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && action === 'mkdir') {
|
||||
const folderName = safeName(body?.name, '');
|
||||
if (!folderName) return json(res, 400, { error: 'Folder name required' });
|
||||
|
||||
const resolved = toFbPath();
|
||||
if (resolved.virtual) return json(res, 400, { error: 'Cannot create folder in virtual directory' });
|
||||
|
||||
await fbFetch(config, 'POST', '/api/resources', {
|
||||
params: { path: joinPath(resolved.fbPath, folderName), isDir: 'true' },
|
||||
});
|
||||
return json(res, 200, { success: true });
|
||||
}
|
||||
|
||||
if (req.method === 'DELETE' && action === 'delete') {
|
||||
const resolved = toFbPath();
|
||||
if (resolved.virtual) return json(res, 400, { error: 'Cannot delete virtual directory' });
|
||||
if (!basename(resolved.fbPath)) return json(res, 400, { error: 'Cannot delete root' });
|
||||
|
||||
await fbFetch(config, 'DELETE', '/api/resources', { params: { path: resolved.fbPath } });
|
||||
return json(res, 200, { success: true });
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && action === 'rename') {
|
||||
const newName = safeName(body?.name, '');
|
||||
if (!newName) return json(res, 400, { error: 'New name required' });
|
||||
|
||||
const resolved = toFbPath();
|
||||
if (resolved.virtual) return json(res, 400, { error: 'Cannot rename virtual directory' });
|
||||
|
||||
const newFbPath = joinPath(parentDir(resolved.fbPath), newName);
|
||||
await fbFetch(config, 'PATCH', '/api/resources', {
|
||||
params: {},
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'rename',
|
||||
items: [{ fromSource: FB_SOURCE, fromPath: resolved.fbPath, toSource: FB_SOURCE, toPath: newFbPath }],
|
||||
overwrite: false,
|
||||
}),
|
||||
});
|
||||
return json(res, 200, { success: true });
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && action === 'archive-move') {
|
||||
// Moves srcPath into dstParentPath. If destination already exists, merges contents recursively.
|
||||
const srcVPath = body?.srcPath;
|
||||
const dstParentVPath = body?.dstParentPath;
|
||||
if (!srcVPath || !dstParentVPath) return json(res, 400, { error: 'srcPath and dstParentPath required' });
|
||||
|
||||
const resolvedSrc = toFbPath(srcVPath);
|
||||
const resolvedDstParent = toFbPath(dstParentVPath);
|
||||
if (resolvedSrc.virtual || resolvedDstParent.virtual) return json(res, 400, { error: 'Cannot operate on virtual directories' });
|
||||
|
||||
async function fbExists(path) {
|
||||
try { await fbFetch(config, 'GET', '/api/resources', { params: { path } }); return true; }
|
||||
catch (e) { if (e.status === 404) return false; throw e; }
|
||||
}
|
||||
|
||||
async function fbMkdir(path) {
|
||||
await fbFetch(config, 'POST', '/api/resources', { params: { path, isDir: 'true' } }).catch(() => {});
|
||||
}
|
||||
|
||||
async function mergeMove(fbSrc, fbDstParent) {
|
||||
const name = basename(fbSrc);
|
||||
const fbDst = joinPath(fbDstParent, name);
|
||||
const destExists = await fbExists(fbDst);
|
||||
if (!destExists) {
|
||||
await fbFetch(config, 'PATCH', '/api/resources', {
|
||||
params: {}, headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'move', items: [{ fromSource: FB_SOURCE, fromPath: fbSrc, toSource: FB_SOURCE, toPath: fbDst }], overwrite: false }),
|
||||
});
|
||||
} else {
|
||||
const data = await fbFetch(config, 'GET', '/api/resources', { params: { path: fbSrc } });
|
||||
const dirs = (data?.folders || []).map(f => f.name);
|
||||
const files = (data?.files || []).map(f => f.name);
|
||||
await fbMkdir(fbDst);
|
||||
for (const dir of dirs) await mergeMove(joinPath(fbSrc, dir), fbDst);
|
||||
for (const file of files) {
|
||||
await fbFetch(config, 'PATCH', '/api/resources', {
|
||||
params: {}, headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'move', items: [{ fromSource: FB_SOURCE, fromPath: joinPath(fbSrc, file), toSource: FB_SOURCE, toPath: joinPath(fbDst, file) }], overwrite: true }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
await fbFetch(config, 'DELETE', '/api/resources', { params: { path: fbSrc } }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
await mergeMove(resolvedSrc.fbPath, resolvedDstParent.fbPath);
|
||||
return json(res, 200, { success: true });
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && action === 'move') {
|
||||
const srcPath = body?.srcPath;
|
||||
const dstPath = body?.dstPath;
|
||||
const mode = body?.mode === 'copy' ? 'copy' : 'move';
|
||||
if (!srcPath || !dstPath) return json(res, 400, { error: 'srcPath and dstPath required' });
|
||||
|
||||
const resolvedSrc = toFbPath(srcPath);
|
||||
const resolvedDst = toFbPath(dstPath);
|
||||
if (resolvedSrc.virtual || resolvedDst.virtual) return json(res, 400, { error: 'Cannot move virtual directories' });
|
||||
|
||||
const itemName = basename(resolvedSrc.fbPath);
|
||||
const newFbPath = joinPath(resolvedDst.fbPath, itemName);
|
||||
|
||||
await fbFetch(config, 'PATCH', '/api/resources', {
|
||||
params: {},
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: mode,
|
||||
items: [{ fromSource: FB_SOURCE, fromPath: resolvedSrc.fbPath, toSource: FB_SOURCE, toPath: newFbPath }],
|
||||
overwrite: false,
|
||||
}),
|
||||
});
|
||||
return json(res, 200, { success: true });
|
||||
}
|
||||
|
||||
return json(res, 405, { error: 'Method not allowed' });
|
||||
return json(res, 200, { ok: true, configured: true, path: fullPath });
|
||||
} catch (error) {
|
||||
return json(res, error.status || 500, { error: error.message || 'Unexpected error' });
|
||||
return json(res, error?.status || 500, { error: error?.message || 'Failed to ensure request folder.' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.
@@ -0,0 +1,379 @@
|
||||
import fs from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const docsDir = new URL('.', import.meta.url);
|
||||
const inputPath = new URL('./checked-sites-revision-audit-2026-06-04.json', docsDir);
|
||||
const outputPath = new URL('./checked-sites-revision-audit-2026-06-04.html', docsDir);
|
||||
|
||||
const rows = JSON.parse(fs.readFileSync(inputPath, 'utf8'));
|
||||
|
||||
const existingRows = rows.filter((row) => row.exists !== false);
|
||||
const activeRows = existingRows.filter((row) => row.hasActiveRevision === 'Yes');
|
||||
const noActiveRows = existingRows.filter((row) => row.hasActiveRevision !== 'Yes');
|
||||
const r00BilledRows = existingRows.filter((row) => row.r00Billed !== 'No');
|
||||
const r00UnbilledRows = existingRows.filter((row) => row.r00Billed === 'No');
|
||||
|
||||
const activeStatusCounts = activeRows.reduce((acc, row) => {
|
||||
const key = row.activeStatus || 'unknown';
|
||||
acc[key] = (acc[key] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const revisionDepthCounts = activeRows.reduce((acc, row) => {
|
||||
const key = row.activeRevision || '—';
|
||||
acc[key] = (acc[key] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const maxStatusCount = Math.max(1, ...Object.values(activeStatusCounts));
|
||||
const maxDepthCount = Math.max(1, ...Object.values(revisionDepthCounts));
|
||||
|
||||
const esc = (value) =>
|
||||
String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
|
||||
function badgeClassForStatus(status) {
|
||||
if (status === 'client_review') return 'review';
|
||||
if (status === 'client_approved') return 'approved';
|
||||
if (status === 'not_started') return 'todo';
|
||||
if (status === 'in_progress') return 'progress';
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
function barChart(entries, maxValue) {
|
||||
return entries
|
||||
.map(([label, count]) => {
|
||||
const pct = Math.max(4, Math.round((count / maxValue) * 100));
|
||||
return `
|
||||
<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));
|
||||
@@ -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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
)
|
||||
|
||||
|
||||
def status_pill(value):
|
||||
value = value or "—"
|
||||
if value == "client_review":
|
||||
return pill(value, REVIEW, REVIEW_SOFT)
|
||||
if value == "client_approved":
|
||||
return pill(value, OK, OK_SOFT)
|
||||
if value == "not_started":
|
||||
return pill(value, TODO, TODO_SOFT)
|
||||
if value == "in_progress":
|
||||
return pill(value, ACCENT, ACCENT_SOFT)
|
||||
if value == "Yes":
|
||||
return pill(value, OK, OK_SOFT)
|
||||
if value == "No":
|
||||
return pill(value, WARN, WARN_SOFT)
|
||||
return pill(value, MUTED, colors.white)
|
||||
|
||||
|
||||
def text_para(value, style_name="cell"):
|
||||
return Paragraph(escape(value), styles[style_name])
|
||||
|
||||
|
||||
def build_summary_cards(rows):
|
||||
existing_rows = [row for row in rows if row.get("exists", True) is not False]
|
||||
active_rows = [row for row in existing_rows if row.get("hasActiveRevision") == "Yes"]
|
||||
billed_rows = [row for row in existing_rows if row.get("r00Billed") != "No"]
|
||||
unbilled_rows = [row for row in existing_rows if row.get("r00Billed") == "No"]
|
||||
|
||||
cards = [
|
||||
("Checked Sites", str(len(existing_rows)), "Sites found in the database from your checked list."),
|
||||
("Active Revisions", str(len(active_rows)), "Checked sites that now have an active R01+."),
|
||||
("R00 Billed", str(len(billed_rows)), "Checked sites where the new-book unit is already billed."),
|
||||
("R00 Unbilled", str(len(unbilled_rows)), "Checked sites where R00 is done but not billed yet."),
|
||||
]
|
||||
|
||||
card_rows = []
|
||||
for label, value, subtext in cards:
|
||||
card_rows.append(
|
||||
Table(
|
||||
[
|
||||
[Paragraph(label.upper(), styles["card_label"])],
|
||||
[Paragraph(value, styles["card_value"])],
|
||||
[Paragraph(subtext, styles["card_sub"])],
|
||||
],
|
||||
colWidths=[3.0 * inch],
|
||||
style=TableStyle(
|
||||
[
|
||||
("BACKGROUND", (0, 0), (-1, -1), colors.white),
|
||||
("BOX", (0, 0), (-1, -1), 0.8, LINE),
|
||||
("INNERPADDING", (0, 0), (-1, -1), 10),
|
||||
("ROUNDEDCORNERS", [10, 10, 10, 10]),
|
||||
]
|
||||
),
|
||||
)
|
||||
)
|
||||
return Table([card_rows], colWidths=[3.1 * inch] * 4, hAlign="LEFT")
|
||||
|
||||
|
||||
def build_table(rows, title, intro):
|
||||
flow = [Paragraph(title, styles["section_title"]), Paragraph(intro, styles["small"]), Spacer(1, 10)]
|
||||
|
||||
header = [
|
||||
text_para("Site", "th"),
|
||||
text_para("DB Title", "th"),
|
||||
text_para("Internal #", "th"),
|
||||
text_para("R00 Done", "th"),
|
||||
text_para("R00 Billed", "th"),
|
||||
text_para("Active R#", "th"),
|
||||
text_para("Active Status", "th"),
|
||||
text_para("Completed Revisions", "th"),
|
||||
text_para("Billed Versions", "th"),
|
||||
text_para("Version Summary", "th"),
|
||||
]
|
||||
data = [header]
|
||||
|
||||
for row in rows:
|
||||
data.append(
|
||||
[
|
||||
text_para(row.get("site", ""), "cell_mono"),
|
||||
text_para(row.get("dbTitle", "")),
|
||||
text_para(row.get("internalTaskNumber", ""), "cell_mono"),
|
||||
status_pill(row.get("r00Completed", "No")),
|
||||
status_pill(row.get("r00Billed", "No")),
|
||||
text_para(row.get("activeRevision", "—"), "cell_mono"),
|
||||
status_pill(row.get("activeStatus", "—")),
|
||||
text_para(row.get("completedRevisions", "—")),
|
||||
text_para(row.get("billedVersions", "—")),
|
||||
text_para(row.get("versionSummary", "—")),
|
||||
]
|
||||
)
|
||||
|
||||
table = LongTable(
|
||||
data,
|
||||
colWidths=[
|
||||
0.78 * inch,
|
||||
1.7 * inch,
|
||||
0.72 * inch,
|
||||
0.75 * inch,
|
||||
0.85 * inch,
|
||||
0.6 * inch,
|
||||
1.0 * inch,
|
||||
1.45 * inch,
|
||||
1.2 * inch,
|
||||
2.15 * inch,
|
||||
],
|
||||
repeatRows=1,
|
||||
)
|
||||
table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("BACKGROUND", (0, 0), (-1, 0), colors.white),
|
||||
("BOX", (0, 0), (-1, -1), 0.8, LINE),
|
||||
("INNERGRID", (0, 0), (-1, -1), 0.4, LINE),
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 6),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 6),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 6),
|
||||
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#FBF8F2")]),
|
||||
]
|
||||
)
|
||||
)
|
||||
flow.append(table)
|
||||
return flow
|
||||
|
||||
|
||||
styles = getSampleStyleSheet()
|
||||
styles.add(
|
||||
ParagraphStyle(
|
||||
name="title_main",
|
||||
fontName="Helvetica-Bold",
|
||||
fontSize=24,
|
||||
leading=28,
|
||||
textColor=TEXT,
|
||||
alignment=TA_LEFT,
|
||||
)
|
||||
)
|
||||
styles.add(
|
||||
ParagraphStyle(
|
||||
name="eyebrow",
|
||||
fontName="Helvetica-Bold",
|
||||
fontSize=9,
|
||||
leading=11,
|
||||
textColor=MUTED,
|
||||
)
|
||||
)
|
||||
styles.add(
|
||||
ParagraphStyle(
|
||||
name="subtitle",
|
||||
fontName="Helvetica",
|
||||
fontSize=11,
|
||||
leading=15,
|
||||
textColor=MUTED,
|
||||
)
|
||||
)
|
||||
styles.add(
|
||||
ParagraphStyle(
|
||||
name="section_title",
|
||||
fontName="Helvetica-Bold",
|
||||
fontSize=15,
|
||||
leading=18,
|
||||
textColor=TEXT,
|
||||
spaceAfter=2,
|
||||
)
|
||||
)
|
||||
styles.add(
|
||||
ParagraphStyle(
|
||||
name="small",
|
||||
fontName="Helvetica",
|
||||
fontSize=9,
|
||||
leading=12,
|
||||
textColor=MUTED,
|
||||
)
|
||||
)
|
||||
styles.add(
|
||||
ParagraphStyle(
|
||||
name="card_label",
|
||||
fontName="Helvetica-Bold",
|
||||
fontSize=8,
|
||||
leading=10,
|
||||
textColor=MUTED,
|
||||
)
|
||||
)
|
||||
styles.add(
|
||||
ParagraphStyle(
|
||||
name="card_value",
|
||||
fontName="Helvetica-Bold",
|
||||
fontSize=24,
|
||||
leading=26,
|
||||
textColor=TEXT,
|
||||
)
|
||||
)
|
||||
styles.add(
|
||||
ParagraphStyle(
|
||||
name="card_sub",
|
||||
fontName="Helvetica",
|
||||
fontSize=8.5,
|
||||
leading=11,
|
||||
textColor=MUTED,
|
||||
)
|
||||
)
|
||||
styles.add(
|
||||
ParagraphStyle(
|
||||
name="th",
|
||||
fontName="Helvetica-Bold",
|
||||
fontSize=8,
|
||||
leading=10,
|
||||
textColor=MUTED,
|
||||
)
|
||||
)
|
||||
styles.add(
|
||||
ParagraphStyle(
|
||||
name="cell",
|
||||
fontName="Helvetica",
|
||||
fontSize=8.2,
|
||||
leading=10.5,
|
||||
textColor=TEXT,
|
||||
)
|
||||
)
|
||||
styles.add(
|
||||
ParagraphStyle(
|
||||
name="cell_mono",
|
||||
fontName="Courier",
|
||||
fontSize=8.1,
|
||||
leading=10.3,
|
||||
textColor=TEXT,
|
||||
)
|
||||
)
|
||||
styles.add(
|
||||
ParagraphStyle(
|
||||
name="pill",
|
||||
fontName="Helvetica-Bold",
|
||||
fontSize=7.5,
|
||||
leading=9,
|
||||
alignment=1,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
rows = json.loads(INPUT_PATH.read_text())
|
||||
existing_rows = [row for row in rows if row.get("exists", True) is not False]
|
||||
active_rows = [row for row in existing_rows if row.get("hasActiveRevision") == "Yes"]
|
||||
no_active_rows = [row for row in existing_rows if row.get("hasActiveRevision") != "Yes"]
|
||||
|
||||
doc = BaseDocTemplate(
|
||||
str(OUTPUT_PATH),
|
||||
pagesize=landscape(legal),
|
||||
leftMargin=36,
|
||||
rightMargin=36,
|
||||
topMargin=28,
|
||||
bottomMargin=28,
|
||||
)
|
||||
frame = Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height, id="main")
|
||||
doc.addPageTemplates([PageTemplate(id="default", frames=[frame], onPage=page_template)])
|
||||
|
||||
story = [
|
||||
Paragraph("FOURGE PORTAL AUDIT", styles["eyebrow"]),
|
||||
Spacer(1, 4),
|
||||
Paragraph("Checked Sites Revision Audit", styles["title_main"]),
|
||||
Spacer(1, 6),
|
||||
Paragraph(
|
||||
"This report treats your checkmark as “R00 was completed,” then separates that from billing and from any newer active revisions. "
|
||||
"It is designed to make client-billing comparison easier at a glance.",
|
||||
styles["subtitle"],
|
||||
),
|
||||
Spacer(1, 18),
|
||||
build_summary_cards(rows),
|
||||
Spacer(1, 18),
|
||||
]
|
||||
|
||||
story.extend(
|
||||
build_table(
|
||||
active_rows,
|
||||
"Checked Sites With Active Revisions",
|
||||
"These are the sites where R00 is done, but there is still a newer revision active or awaiting billing.",
|
||||
)
|
||||
)
|
||||
story.append(PageBreak())
|
||||
story.extend(
|
||||
build_table(
|
||||
no_active_rows,
|
||||
"Checked Sites With No Active Revision",
|
||||
"These are the checked sites that currently do not have a newer active revision.",
|
||||
)
|
||||
)
|
||||
|
||||
doc.build(story)
|
||||
print(OUTPUT_PATH)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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
@@ -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
@@ -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 |
|
||||
@@ -9,6 +9,8 @@
|
||||
document.documentElement.style.colorScheme = theme === 'light' ? 'light' : 'dark';
|
||||
})();
|
||||
</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/png" sizes="32x32" href="/favicon-32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png" />
|
||||
|
||||
@@ -15,6 +15,15 @@ This is the single source of truth for dashboard/profile visual structure and UI
|
||||
- radial glow + vertical dark gradient.
|
||||
- Light base token `--bg` is full gradient:
|
||||
- gray radial glow + white vertical gradient.
|
||||
- A faint ambient grid may sit above the base gradient but behind all app content:
|
||||
- use a fixed `body::before` layer with very low-contrast line color
|
||||
- keep the static grid a touch more visible than before, but still quiet
|
||||
- any animation should be tiny spark nodes that travel on the grid lines, not wide screen sweeps
|
||||
- use several small sparks across the page with varied directions so the motion feels distributed rather than like one falling line
|
||||
- avoid additional decorative shell/menu entrance animations so the sparks are the only ambient page motion
|
||||
- keep animation slow and drifting, not pulsing or flashy
|
||||
- keep the grid masked/faded so it is strongest near the top and softer lower in the page
|
||||
- Auth/login screens use the same shared `body` background and spark effect as the app shell; auth surfaces should stay transparent around the card so the global background remains visible.
|
||||
- Do not use `html` theme-gradient scripting for Safari chrome behavior.
|
||||
|
||||
## 3) Tokens
|
||||
@@ -66,12 +75,28 @@ This is the single source of truth for dashboard/profile visual structure and UI
|
||||
- **`margin-bottom: 10` is the site-wide standard gap between any tab/control bar and the card(s) below — use this everywhere, no exceptions**
|
||||
- tab bar row must always be `min-height: var(--btn-height)` so the gap to the card never shifts when action buttons appear/disappear
|
||||
- tabs stay on the left in source order
|
||||
- when a tab has a count, use the same inline badge pattern as task detail tabs:
|
||||
- `margin-left: 5`
|
||||
- `font-size: 12`
|
||||
- `font-weight: 600`
|
||||
- `background: var(--card-bg-2)`
|
||||
- `border: 1px solid var(--border)`
|
||||
- `border-radius: 10px`
|
||||
- `padding: 3px 8px`
|
||||
- active tab count uses `var(--accent)` text; inactive uses `var(--text-muted)`
|
||||
- action buttons group sits on the right using: `margin-left: auto`, `display: flex`, `align-items: center`, `gap: 8`
|
||||
- do not use hardcoded spacer blocks (`width` filler divs) to force alignment
|
||||
- icon-only filter/action buttons share the same row and align vertically with add buttons
|
||||
- filter button wrapper: `<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>` — `display: flex` prevents block stretching that misaligns the button vertically
|
||||
- filter button: `className="btn btn-outline"` + `style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}`, icon only (no label), funnel SVG `<path d="M2 4h12M4 8h8M6 12h4" />` at `13×13`; dropdown uses `site-header-avatar-menu` + `site-header-avatar-item` classes
|
||||
- when the control bar uses a multi-column grid (to align with split-card layouts below), add `align-items: center` to the grid and `min-height: var(--btn-height)` to every column so row height is stable across all tab states
|
||||
- Tasks/Projects page tab rules:
|
||||
- default landing tab is `New Requests`
|
||||
- keep `All Tasks`, `In Progress`, `On Hold`, `In Review`, and `Completed`
|
||||
- replace the old `To Do` tab with:
|
||||
- `New Requests` for `not_started` rows on `R00`
|
||||
- `Revisions` for `not_started` rows on `R01+`
|
||||
- the project table tabs on the right also use the same count badge pattern
|
||||
|
||||
## 7) Dashboard Grids (Team)
|
||||
- Stat row: `grid-template-columns: 1fr 1fr 1fr 1.5fr`, `gap: 24`, `margin-bottom: 0`
|
||||
@@ -112,7 +137,7 @@ This is the single source of truth for dashboard/profile visual structure and UI
|
||||
- Meta/date text: `11px`
|
||||
- Progress track: `height: 4px`, `radius: 2px`
|
||||
- Percentage width slot: `min-width: 28px`
|
||||
- Empty states in all cards (dashboard, profile, tasks, projects, file sharing, etc.):
|
||||
- Empty states in all cards (dashboard, profile, tasks, projects, etc.):
|
||||
- any `No ...` message is centered in the card body (`display:flex`, `align-items:center`, `justify-content:center`)
|
||||
- use shared class treatment (`.card-empty-center`) for consistency
|
||||
- avoid top-offset-only placement for empty text
|
||||
@@ -135,8 +160,6 @@ This is the single source of truth for dashboard/profile visual structure and UI
|
||||
- row vertical spacing via cell padding: typically `5px`
|
||||
- Hot Tasks column widths:
|
||||
- check `10%`, task `40%`, requested by `35%`, due by `15%`
|
||||
- Client Highlight column widths:
|
||||
- icon `5%`, company `22%`, contact `23%`, projects `13%`, open `13%`, outstanding `12%`, paid `12%`
|
||||
- Sorting rule:
|
||||
- Every visible data column header must be sortable.
|
||||
- Use clickable header controls (`SortTh`) with ascending/descending indicator.
|
||||
@@ -173,57 +196,58 @@ This is the single source of truth for dashboard/profile visual structure and UI
|
||||
- Activity feed includes:
|
||||
- actions performed by the viewed user
|
||||
- `Task approved` and `Task rejected` entries for tasks assigned to that viewed user (even if actioned by someone else)
|
||||
## 12.5) File Sharing
|
||||
- Main split: `grid-template-columns: minmax(0, 1fr) minmax(0, 3fr)` for an effective `25 / 75` layout, `gap: 24`
|
||||
- Left stacked cards (`Pinned`, `Navigation`): `gap: 24`
|
||||
- Canonical folder structure:
|
||||
- team and subcontractor profile folders live at `Team/<User Name>`
|
||||
- company folders live at `Clients/<Company Name>`
|
||||
- project folders live at `Clients/<Company Name>/Projects/<Project Name>`
|
||||
- every project auto-creates `00 Project Files`
|
||||
- task folders live at `Clients/<Company Name>/Projects/<Project Name>/<Task Name>`
|
||||
- every task auto-creates `Request Info`, `Working Files`, and `Old Books`
|
||||
- request submission files are copied into `Request Info/R00`, `R01`, and higher revision folders
|
||||
- renaming a team user, subcontractor user, company, project, or task must rename the matching filesystem folder
|
||||
- folder repair/backfill is additive only: create missing folders, leave existing folders untouched
|
||||
- request-info backfill must skip files that already exist and must not overwrite added working files or user-managed files
|
||||
- deleting a task in the portal moves its task folder into `Archive/Clients/<Company Name>/Projects/<Project Name>/<Task Name>` instead of deleting it
|
||||
- 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
|
||||
- orphan team folders move to `Archive/Team/<Folder Name>` when no team or subcontractor profile matches that folder name
|
||||
- orphan project folders move to `Archive/Clients/<Company Name>/Projects/<Project Name>` when no portal project matches that folder name under that company
|
||||
- orphan reconciliation does not remove company roots or overwrite archived content; it merge-moves into archive
|
||||
- Access/home rules:
|
||||
- team members have full file access
|
||||
- client users with exactly one tied company use that company folder as home: `/Clients/<Company Name>`
|
||||
- client users tied to multiple companies use `/Clients` as home and only see company folders they are tied to
|
||||
- `/Clients` acts as a virtual parent for multi-company client users and must not expose unrelated company folders
|
||||
- subcontractor users use virtual home `/`
|
||||
- subcontractor virtual home shows `Team` plus only the project folders they are assigned to
|
||||
- subcontractor users skip all other root folders and must not see unrelated client or internal roots
|
||||
- subcontractor `Team` maps to the real shared `Team` folder
|
||||
- each subcontractor project folder is a virtual root that maps to the real client project path `/Clients/<Company>/Projects/<Project>`
|
||||
- Right card header:
|
||||
- title label is `File Browser`
|
||||
- drag/drop note sits directly under the title and uses subtext sizing: `12px`
|
||||
- current file path uses the same `11px` uppercase label treatment as the title row family
|
||||
- toolbar does not show `+ Folder` or `Upload` buttons
|
||||
- upload happens by dragging and dropping files or folders into the right card
|
||||
- delete action uses the same geometry as the profile edit button (`top: 18px`, `right: 21px`, `8px` radius, `22px` height, `12px` text, `0 12px` padding) but uses danger styling with red hover/fill
|
||||
- Right card table:
|
||||
- uses dashboard table styling (`table-layout: fixed`, transparent header row, `10px` uppercase headers, `13px/12px` body text)
|
||||
- card padding matches dashboard widget shell (`18px 21px`)
|
||||
- clickable text inside the table highlights in accent; rows do not use block hover fill
|
||||
- body cells are borderless like dashboard card tables
|
||||
- body cell padding matches dashboard tables exactly: `5px`
|
||||
- visible headers (`Name`, `Size`, `Modified`) use shared sortable header controls
|
||||
- file/folder icon footprint is compact so row height stays on the same cadence as dashboard tables
|
||||
- alternating rows use a very subtle theme-aware tint for scanability
|
||||
- Navigation tree:
|
||||
- `Home` aligns to the first arrow column
|
||||
- connector line runs through the arrow center column
|
||||
- pinned folders are user-specific and must not be shared across users on the same device/browser session
|
||||
- hover is text-only accent color, no row fill
|
||||
|
||||
## 12.25) Shared Portrait Logic
|
||||
- Shared profile/avatar rendering must use one common resolver path instead of page-specific inline `<img>` fallbacks.
|
||||
- Canonical shared component: `ProfileAvatar`
|
||||
- Portrait source resolution order:
|
||||
- explicit `avatarUrl` prop when already resolved
|
||||
- `profile.avatar_url`
|
||||
- profile lookup by `profileId`
|
||||
- profile lookup by normalized display `name` as a fallback when only sender/actor name exists in derived dashboard data
|
||||
- initials fallback when no profile image is available
|
||||
- Shared portrait behavior:
|
||||
- image uses `object-fit: cover`
|
||||
- shape is always circular (`border-radius: 50%`)
|
||||
- fallback initials use accent background with dark text
|
||||
- layout/header/task/project/dashboard surfaces should reuse the shared portrait resolver instead of hand-rolling image + initials logic
|
||||
|
||||
## 12.3) Team Dashboard Data Logic
|
||||
- Team dashboard is shared across roles, but card data is role-filtered by scoped tasks/projects/companies.
|
||||
- Focus refresh + realtime refresh should be wired through the shared live-refresh path, not duplicated per page.
|
||||
- Scoped company/project/task IDs should be resolved through the shared scope resolver for client/external views.
|
||||
- Tall dashboard list cards should use viewport-driven height with internal scroll instead of hard-coded row counts:
|
||||
- measure the lower dashboard row against the current window height and size the cards to the remaining viewport space
|
||||
- do not use a generic fixed tall-card height that can force whole-page scrolling on shorter windows
|
||||
- do not apply a lower clamp that stops the card from continuing to compress with the window
|
||||
- scrolling stays inside the card body, not on the full page layout
|
||||
- `Team Performance` card logic:
|
||||
- source data is `deliveries` joined to `submissions` and `tasks`
|
||||
- each delivery row must include `submission.task_id` plus nested task assignee info
|
||||
- non-team roles must filter deliveries back down to their scoped task IDs before calculating performance rows
|
||||
- month dropdown options are derived only from months that actually contain scoped delivery data
|
||||
- initial render must use the first valid month with data; card must not render empty until the dropdown is manually changed
|
||||
- performer identity resolves first from `task.assigned_to`, then falls back to normalized `sent_by` / display name matching against profiles
|
||||
- avatar in `Team Performance` follows shared portrait resolution rules above
|
||||
- counts:
|
||||
- `version_number === 0` => `new`
|
||||
- `version_number > 0` => `revision`
|
||||
- progress bar percentage is performer completed count divided by total completed count in the active month
|
||||
- Dashboard lower-left task card logic:
|
||||
- team view remains `Hot Tasks`
|
||||
- client view remains `Tasks Ready For Review`
|
||||
- external/subcontractor view is `My Tasks`
|
||||
- subcontractor `My Tasks` must source directly from scoped tasks assigned to the current subcontractor, not from `is_hot` submission flags
|
||||
- subcontractor `My Tasks` should include only active assigned tasks:
|
||||
- `not_started`
|
||||
- `in_progress`
|
||||
- `client_review`
|
||||
- `on_hold`
|
||||
- subcontractor `My Tasks` should exclude completed/closed states such as:
|
||||
- `client_approved`
|
||||
- `invoiced`
|
||||
- `paid`
|
||||
- subcontractor `My Tasks` rows should show task, project, status, and due date
|
||||
|
||||
## 13) Radius + Geometry Rules
|
||||
- Dashboard/profile widgets: `8px` radius
|
||||
@@ -250,6 +274,22 @@ This is the single source of truth for dashboard/profile visual structure and UI
|
||||
- light mode: outline labels use light-theme primary text; danger labels use danger text
|
||||
- outline button border uses the same `var(--border)` token as card outlines unless a button role explicitly overrides it
|
||||
- keep existing button role styling intact (`outline`, `primary`, `danger`); geometry changes, not role colors
|
||||
- popup/modal footer action rows are a no-wrap horizontal row:
|
||||
- `display: flex`
|
||||
- `justify-content: flex-end`
|
||||
- `gap: 8px`
|
||||
- `flex-wrap: nowrap`
|
||||
- popup/modal action buttons use a stable minimum width so loading labels do not shove adjacent buttons underneath:
|
||||
- `min-width: 108px`
|
||||
- center content horizontally
|
||||
- request/add-task popups, review/revision/reject/amend popups, and other form submit dialogs should keep the save/submit/cancel buttons on one line while the submit label changes to `Saving...` / `Submitting...`
|
||||
- status action buttons that change the task state must update the page immediately and lock while the save is in flight so users cannot double-press:
|
||||
- flip local UI state optimistically
|
||||
- disable related status buttons until request finishes
|
||||
- if save fails, roll back local state and show error
|
||||
- route/page loading should continue using the shared `PageLoader` until page data is actually ready
|
||||
- avoid plain page-level `Loading...` text fallbacks after navigation, or the popup can disappear while the route is still fetching
|
||||
- pages with longer initial fetches should pass their route loading state into shared layout/page shells so the popup stays up until all first-load data for that screen is ready
|
||||
- Inputs/dropdowns may use their own control radius unless a page-specific rule says otherwise
|
||||
- Circular elements (avatar/day/icon badges): `50%`
|
||||
|
||||
@@ -261,7 +301,7 @@ This is the single source of truth for dashboard/profile visual structure and UI
|
||||
|
||||
## 14.5) Loading Popup
|
||||
- Use shared `PageLoader` component for loading overlays instead of page-specific popup implementations.
|
||||
- Loading popups/overlays, including shared site-wide page loaders and file-sharing loading states, use the same widget shell as dashboard cards:
|
||||
- Loading popups/overlays, including shared site-wide page loaders, use the same widget shell as dashboard cards:
|
||||
- `background: var(--popup-bg)`
|
||||
- `border: 1px solid var(--border)`
|
||||
- `border-radius: 8px`
|
||||
@@ -289,6 +329,13 @@ This is the single source of truth for dashboard/profile visual structure and UI
|
||||
- textarea text: `13px`, color `var(--text-primary)`
|
||||
- field surfaces and borders use global theme tokens (`var(--card-bg-2)` + `var(--border)`) for dark/light parity
|
||||
- modal form action buttons use the standard outline geometry/style (`btn btn-outline`) unless a page explicitly defines a different role
|
||||
- Finance modal numeric fields (expense amount, invoice money inputs, line-item quantity/rate fields) follow the same shell system instead of custom oversized styles:
|
||||
- amount/currency field outer shell: `min-height: 42px`, `border-radius: 6px`, `padding: 0 12px`, `background: var(--card-bg-2)`, `border: 1px solid var(--border)`
|
||||
- currency symbol is separate from the numeric input and uses secondary text
|
||||
- amount input text: `22px`, `500`, `line-height: 1.1`, right aligned, `font-variant-numeric: tabular-nums`
|
||||
- line-item numeric inputs keep the normal field feel: `min-height: 32px`, `13px` text
|
||||
- quantity is centered; rates/prices/totals are right aligned with tabular numerals
|
||||
- do not use giant red standalone number inputs; red is reserved for negative/danger/read-only emphasis, not default editable money entry
|
||||
|
||||
## 14.7) Status Tags
|
||||
- Shared status tags use `StatusBadge` (`.badge.badge-status`).
|
||||
@@ -329,7 +376,7 @@ This is the single source of truth for dashboard/profile visual structure and UI
|
||||
- fast `160ms`
|
||||
- base `220ms`
|
||||
- easing `cubic-bezier(0.22, 1, 0.36, 1)`
|
||||
- Dropdown animation: `ui-fade-up` from `translateY(4px)` + opacity 0 -> 1
|
||||
- Dropdown/menu entrance animation should be minimal; avoid decorative motion when it competes with the shared background spark effect.
|
||||
|
||||
## 17) Hover Interaction Contract
|
||||
- Sidebar, header icon buttons, dropdown items, and avatar menu items must show a visible hover surface before click.
|
||||
@@ -341,10 +388,6 @@ This is the single source of truth for dashboard/profile visual structure and UI
|
||||
- Hover and active must be visually distinct:
|
||||
- hover uses stronger temporary contrast (`bg` + thin border),
|
||||
- active remains persistent selected-state background.
|
||||
- File Sharing left navigation exception:
|
||||
- `Pinned` + `Navigation` tree rows use text-only hover highlight (`color: var(--accent)`).
|
||||
- Do not apply row background fill for hover in this section.
|
||||
|
||||
## 17.5) Link Interaction Standard
|
||||
- Use shared link classes only; do not hand-roll page-specific link hover styles:
|
||||
- table/text links inside cards and tables: `table-link`
|
||||
@@ -357,7 +400,7 @@ This is the single source of truth for dashboard/profile visual structure and UI
|
||||
- Do not rely on inline style color overrides for hover behavior; if a variant is needed, add/extend a shared class in `index.css`.
|
||||
|
||||
## 16) Non-Negotiable Implementation Rules
|
||||
- Keep gradient backgrounds on `html`, not `body`
|
||||
- Keep gradient backgrounds on `body`, not `html`
|
||||
- Keep widget shell values (`18px 21px`, `8px`, blur 12, border token) consistent
|
||||
- Maintain global `24px` spacing rhythm for page/frame/grid gaps
|
||||
- Keep team dashboard card order:
|
||||
@@ -366,9 +409,351 @@ This is the single source of truth for dashboard/profile visual structure and UI
|
||||
3. Net Profit
|
||||
4. Revenue (wide)
|
||||
- Keep row 2 order: Recent Activity (left), Tasks In Progress (center), Calendar (right fixed at `280px`)
|
||||
- Keep the lower dashboard row shared across all three roles:
|
||||
1. role-specific task list card
|
||||
2. Team Performance
|
||||
- Do not add an extra team-only `Client Highlight` card under the shared dashboard rows
|
||||
|
||||
## 17) Team Reports
|
||||
- Team-only tools nav includes `Reports`.
|
||||
- Reports page is a finance-adjacent audit surface, but separate from the invoice page.
|
||||
- Primary report source is task-version billing units, not lump tasks:
|
||||
- `R00` is its own row
|
||||
- each real revision version (`R01`, `R02`, and so on) is its own row
|
||||
- Report rows must derive from the same version-unit billing logic as invoice creation:
|
||||
- initial/new rows map to task-level client invoice units
|
||||
- revision rows map to submission-level client invoice units
|
||||
- hidden review-shadow submissions must not create report rows
|
||||
- Minimum columns:
|
||||
- company
|
||||
- project
|
||||
- task name
|
||||
- `R##`
|
||||
- type (`New` / `Revision`)
|
||||
- version workflow status
|
||||
- billing status tag (`Not Invoiced`, `Invoiced`, `Paid`)
|
||||
- Reports page must support filtering by:
|
||||
- company
|
||||
- project
|
||||
- Reports page must support PDF export of the currently filtered rows.
|
||||
|
||||
## 18) Invoice Rules
|
||||
- New work (`R00`) is billed at the new-service rate.
|
||||
- Revision `R01` is free.
|
||||
- Revisions `R02+` are billed incrementally: each uninvoiced revision entry contributes exactly `1x` revision charge.
|
||||
- Previously invoiced revisions are excluded; later invoices only charge newly uninvoiced revisions (e.g. if `R02` was already invoiced, at `R04` only `R03` + `R04` are billed).
|
||||
- Billing is version-unit based, not task-lump based:
|
||||
- `R00` is one unit
|
||||
- `R01` is one unit
|
||||
- `R02+` are separate units
|
||||
- Team -> client invoice availability:
|
||||
- a task/version can appear only if it is client approved
|
||||
- it must not have been invoiced to the client before
|
||||
- already invoiced or paid client invoice units must not appear again
|
||||
- revision availability must be sourced per version submission, not blocked just because the task's `R00` was already invoiced
|
||||
- invoice add-item UI should show a visible type chip for each unit:
|
||||
- `New` for `R00`
|
||||
- `Revision` for `R01+`
|
||||
- version availability is tied to completed version state, not only the task's current active state:
|
||||
- if a newer version is now active (`not_started`, `in_progress`, etc.), older completed uninvoiced versions must still appear
|
||||
- example: if `R02` is now active, uninvoiced `R01` may still appear while `R02` must stay off the invoice list until it is completed/approved
|
||||
- team invoice popup and standalone team invoice page must both use the same shared version-eligibility source, not separate page-local rules
|
||||
- clicking a team invoice from the `/finances` invoice list should open the same shared invoice-detail popup content used by the standalone team invoice detail route, not a separate simplified modal
|
||||
- creating a new team invoice should also open that same shared invoice-detail popup after save
|
||||
- Subcontractor -> Fourge invoice availability:
|
||||
- a task/version can appear only if that subcontractor actually worked/delivered that version
|
||||
- it must be client approved
|
||||
- it must not have been invoiced by that subcontractor to Fourge before
|
||||
- already invoiced or paid subcontractor invoice units must not appear again
|
||||
- Client invoice history and subcontractor invoice history are separate streams:
|
||||
- the same task/version may exist in client billing and subcontractor billing
|
||||
- duplicate prevention happens within each stream separately
|
||||
|
||||
## 18.0) Request And Task Lifecycle
|
||||
- A task starts as a request created by `team` or `client`.
|
||||
- The initial request is version `R00`.
|
||||
- `R00` request data lives on the initial `submission` row.
|
||||
- Visible `R##` labels on task lists/cards/detail must use the task's true current version:
|
||||
- derive from `max(tasks.current_version, submission.version_number)`
|
||||
- do not derive the visible `R##` from the deadline-bearing submission row
|
||||
- deadline/request metadata may come from the deadline source submission, but the displayed revision number must still reflect the true current version
|
||||
- Client and team can amend the current request without creating a new visible revision item:
|
||||
- amendment stays tied to the same active version
|
||||
- amendment does not increment `R##`
|
||||
- Team or subcontractor works the task and places it in review.
|
||||
- Review submissions belong in the `Submissions` tab and may happen multiple times for the same `R##`.
|
||||
- Client decision on a review:
|
||||
- `Approve`:
|
||||
- task moves to `client_approved`
|
||||
- task remains on the approved version
|
||||
- `Reject`:
|
||||
- task returns to `in_progress`
|
||||
- same assigned worker stays on the task
|
||||
- `R##` does not increment by rejection alone
|
||||
- rejected reason is stored as `Rejected Notes` tied to that same version
|
||||
- A real revision request creates the next visible version:
|
||||
- `R00` -> `R01`
|
||||
- `R01` -> `R02`
|
||||
- etc.
|
||||
- when a revision request creates the next `R##`, the task keeps the same last assignee instead of clearing assignment
|
||||
- status resets to `not_started`, but the assignee remains attached so the work comes back to the same person by default
|
||||
- Revision requests are separate from rejection notes:
|
||||
- revision request = new visible request/version item
|
||||
- rejection note = side note on the current version
|
||||
- Re-review after rejection must still appear in `Submissions` without creating a fake new visible request/revision row.
|
||||
|
||||
## 18.1) Task File Placement Rules
|
||||
- Company and project folder chain:
|
||||
- when a new company is created by team, the portal ensures a matching server folder exists:
|
||||
- `/Clients/{Company}`
|
||||
- when a company name is changed, the company folder is renamed to match
|
||||
- when a new subcontractor is created by team, the portal ensures a matching server folder exists:
|
||||
- `{FILEBROWSER_SUBS_ROOT}/{Subcontractor Name}`
|
||||
- when a subcontractor name is changed, that subcontractor folder is renamed to match
|
||||
- when a new project is created, the portal ensures a matching server folder exists:
|
||||
- `/Clients/{Company}/Projects/{Project}`
|
||||
- inside each new project folder, the portal also pre-creates:
|
||||
- `00 Project Files`
|
||||
- when a project name is changed, the project folder is renamed to match
|
||||
- when a new task request is created, the portal ensures a matching server folder exists:
|
||||
- `/Clients/{Company}/Projects/{Project}/{Task}`
|
||||
- inside each new task folder, the portal also pre-creates:
|
||||
- `Old Books`
|
||||
- `Working Files`
|
||||
- `Survey`
|
||||
- folder creation now works via FileBrowser API using:
|
||||
- a valid production `FILEBROWSER_TOKEN`
|
||||
- FileBrowser source `srv`
|
||||
- folder sync uses FileBrowser only for server folder creation
|
||||
- this does not restore the old file-sharing page or task folder tab
|
||||
- Initial task request files:
|
||||
- created by `team` or `client`
|
||||
- attach to the initial request in the `Overview` tab
|
||||
- stored on the initial `submission`
|
||||
- file records live in `submission_files`
|
||||
- request file flow now works in this order:
|
||||
- ensure the matching task folder exists under `/Clients/{Company}/Projects/{Project}/{Task}`
|
||||
- upload each attached file into Supabase bucket `submissions`
|
||||
- store the file row in `submission_files`
|
||||
- then mirror that same saved file from Supabase into the server-side `Survey` folder
|
||||
- initial request files are stored in Supabase under the task's `Survey` path for portal access
|
||||
- storage path format:
|
||||
- `{taskId}/Survey/{timestamp}_{originalFileName}`
|
||||
- initial request files are also mirrored into the server-side task folder:
|
||||
- `/Clients/{Company}/Projects/{Project}/{Task}/Survey`
|
||||
- the mirror now copies from the saved Supabase storage object on the server side instead of re-uploading the browser file body
|
||||
- multiple attached request files are processed one by one through that same mirror flow and should all land in the same `Survey` folder
|
||||
- the live working behavior is:
|
||||
- request attachments still show inside the portal from Supabase
|
||||
- the same attachments also appear in the matching server `Survey` folder
|
||||
- for `Brand Book` requests, sign details are stored directly on the `submission` row
|
||||
- sign detail payload lives in `submissions.signs`
|
||||
- old `submission_signs` rows are migrated into `submissions.signs` and no longer used by the portal
|
||||
- sign info in the `Overview` tab renders as plain text rows with no inset background card
|
||||
- Revision request files:
|
||||
- created by `team` or `client`
|
||||
- attach to the specific revision entry in the `Revisions` tab
|
||||
- stored on the revision `submission`
|
||||
- file records live in `submission_files`
|
||||
- Review submission files:
|
||||
- created by `team` or `subcontractor`
|
||||
- attach to the specific submitted review entry in the `Submissions` tab
|
||||
- stored on the `delivery`
|
||||
- file records live in `delivery_files`
|
||||
- Client rejection notes:
|
||||
- when client rejects a task in review, it must not create a new request/revision item
|
||||
- rejection opens a note box
|
||||
- rejection returns the task to `in_progress`
|
||||
- rejection keeps the same assigned worker on the task
|
||||
- rejection does not increment the `R##` version by itself
|
||||
- note is tied to the exact version being rejected
|
||||
- `R00` rejection note shows beside the `R00` request info
|
||||
- `R01` rejection note shows beside the `R01` request info
|
||||
- `R02` rejection note shows beside the `R02` request info
|
||||
- label should read `Rejected Notes`
|
||||
- these notes are separate from normal comments and should not inflate the visible comments count
|
||||
|
||||
## 18.2) Task Notification Rules
|
||||
- New request:
|
||||
- when a new request is created, it emails:
|
||||
- all team members
|
||||
- this applies from client request flows and team request flows
|
||||
- In review:
|
||||
- when a task is placed in review, it emails:
|
||||
- the client
|
||||
- all team members
|
||||
- the assigned user
|
||||
- On hold:
|
||||
- when a task is placed on hold, it emails:
|
||||
- the client
|
||||
- all team members
|
||||
- the assigned user
|
||||
- Amend request:
|
||||
- when a request is amended, it emails:
|
||||
- the client
|
||||
- all team members
|
||||
- the assigned user
|
||||
- Rejected:
|
||||
- when a client rejects a task, it emails:
|
||||
- the client
|
||||
- all team members
|
||||
- the assigned user
|
||||
- rejected task email uses the shared task-status layout only
|
||||
- do not also send a separate legacy revision email
|
||||
- Approved:
|
||||
- when a client approves a task, it emails:
|
||||
- the client
|
||||
- all team members
|
||||
- the assigned user
|
||||
- Revision request:
|
||||
- when a revision request is created, it emails:
|
||||
- the client
|
||||
- all team members
|
||||
- the assigned user
|
||||
- revision request should send one shared task-status email only
|
||||
- do not also send any older legacy revision email
|
||||
|
||||
## 19) Finance Flow
|
||||
- Finance is role-split, not one shared page across all users:
|
||||
- team finance hub: `/finances`
|
||||
- client invoice view: `/client-invoices`
|
||||
- subcontractor invoice view: `/subs-invoices`
|
||||
- subcontractor PO view: `/my-purchase-orders`
|
||||
- Finance is shared in concept, but not one universal page component yet:
|
||||
- team sees the full finance hub
|
||||
- client sees receive/view/pay invoice flow only
|
||||
- subcontractor sees invoice-Fourge and PO-review flow only
|
||||
|
||||
### 19.1) Team -> Client Invoice Flow
|
||||
- Team creates invoice for the client.
|
||||
- Team invoice availability is version-unit based:
|
||||
- `R00` can bill as new work
|
||||
- `R01` is tracked but free
|
||||
- `R02+` can bill as revision units
|
||||
- A client invoice unit may appear only if:
|
||||
- it is client approved
|
||||
- it has not already been invoiced to the client
|
||||
- Team sends invoice to the client.
|
||||
- Invoice send email goes to:
|
||||
- the client recipient
|
||||
- all team members
|
||||
- Client does not create invoices back to Fourge in the portal.
|
||||
- Client only receives, views, pays, and downloads invoice records.
|
||||
- Client payment can happen:
|
||||
- through Stripe / portal pay flow
|
||||
- outside the portal, then team marks the invoice paid manually
|
||||
- Once paid:
|
||||
- invoice status is `paid`
|
||||
- branded paid/receipt email goes to:
|
||||
- the client recipient
|
||||
- all team members
|
||||
- receipt can be generated/downloaded/sent if needed
|
||||
- paid client invoices must not be deletable by client or team
|
||||
- Team invoice detail remains the control point for:
|
||||
- send / resend invoice
|
||||
- mark paid
|
||||
- generate invoice PDF
|
||||
- generate/send receipt
|
||||
|
||||
### 19.2) Client Finance Role
|
||||
- Clients only see invoices relevant to their company access.
|
||||
- Clients can:
|
||||
- view invoices
|
||||
- filter invoices by company when tied to multiple companies
|
||||
- download invoice PDFs
|
||||
- Clients cannot:
|
||||
- create invoices to Fourge
|
||||
- manage expenses
|
||||
- manage subcontractor invoices
|
||||
- manage POs
|
||||
|
||||
### 19.3) Subcontractor -> Fourge Invoice Flow
|
||||
- Subcontractor creates invoice to bill Fourge for completed work.
|
||||
- Subcontractor billing is version-based, not task-lump based:
|
||||
- `R00` is one billable unit
|
||||
- `R01` is one version unit but free
|
||||
- `R02+` are version units and billable
|
||||
- Subcontractor invoice create page builds available rows from actual delivered versions tied to that subcontractor's assigned tasks.
|
||||
- Only versions the subcontractor actually delivered should appear as available invoice rows.
|
||||
- Subcontractor invoice availability must dedupe per `task + version`.
|
||||
- If a subcontractor invoice already includes a specific version, that same version must not show up again in later subcontractor invoices.
|
||||
- A subcontractor task/version is available only when:
|
||||
- it is client approved
|
||||
- that subcontractor delivered/worked that version
|
||||
- it has not already been invoiced to Fourge by that subcontractor
|
||||
- Version availability follows the same older-completed-version rule as team client invoices:
|
||||
- if a newer version is now active (`not_started`, `in_progress`, etc.), older completed uninvoiced versions must still appear
|
||||
- example: if `R02` is now active, uninvoiced `R01` may still appear while `R02` must stay off the subcontractor invoice list until it is completed/approved
|
||||
- subcontractor invoice create page must use that same shared version-eligibility source as the team invoice flows
|
||||
- That invoice appears to team finance for review/payment.
|
||||
- Team marks subcontractor invoices `paid`.
|
||||
- Subcontractors do not normally mark their own invoices paid.
|
||||
- Once paid:
|
||||
- subcontractor sees paid status
|
||||
- branded paid/receipt email goes to:
|
||||
- that subcontractor
|
||||
- all team members
|
||||
- receipt can be generated/downloaded/sent if needed
|
||||
- Client invoicing and subcontractor invoicing are separate streams:
|
||||
- team can invoice the client for a task/version
|
||||
- subcontractor can invoice Fourge for that same task/version
|
||||
- Client invoice state must not block subcontractor invoice availability by itself.
|
||||
- Subcontractor invoice state only blocks the same subcontractor task/version from being billed again.
|
||||
- Subcontractor finance page `/subs-invoices` stays separate from the team finance hub:
|
||||
- no chart on this page
|
||||
- top row uses four stat cards only: completed new tasks, completed revisions, invoiced, paid
|
||||
- invoice history sits below in one full-width card with no secondary right-side companion card
|
||||
- the `+ Invoice` action uses the same shared popup shell/button treatment as the team finance invoice modal
|
||||
- subcontractor invoice modal keeps subcontractor-specific fields and billing rules, but visually follows the shared finance popup pattern
|
||||
- clicking a subcontractor invoice from the list should open the shared invoice detail in a popup, matching the team-side subcontractor invoice interaction
|
||||
- after a subcontractor creates an invoice, the portal should open that same popup detail view instead of routing away to a separate detail page
|
||||
- subcontractor invoice popup layout should match the team-side subcontractor invoice popup layout
|
||||
- subcontractor invoice popup action row should include:
|
||||
- `Download Invoice`
|
||||
- `Submit to Team` when still draft
|
||||
- `Download Receipt` when paid
|
||||
- subcontractor invoice popup line-item table should include:
|
||||
- `Work` (`New` or `Revision`)
|
||||
- `R#` (`R00`, `R01`, and so on)
|
||||
- `Description`
|
||||
- `Type` (task service type such as `Brand Book`, `Sign Family`, and so on)
|
||||
- `Qty`
|
||||
- `Unit Price`
|
||||
- `Amount`
|
||||
- subcontractor invoice detail uses one shared layout across team and subcontractor roles, with role-specific actions loaded into the same detail shell
|
||||
- shared subcontractor invoice detail pattern:
|
||||
- uses the shared site header chevron/back pattern from task detail, not a separate page-local header block
|
||||
- content starts immediately under that shared header using the same task-detail rhythm, with no separate action bar floating above the first card row
|
||||
- header title styling follows the global site header spec:
|
||||
- `28px`
|
||||
- `500`
|
||||
- `var(--text-primary)`
|
||||
- `line-height: 1.2`
|
||||
- header subtitle styling follows the global site header spec:
|
||||
- `13px`
|
||||
- `var(--text-muted)`
|
||||
- two top summary cards are the first content row, using the same `24px` gap rhythm as task detail
|
||||
- role-specific invoice actions sit inside the top-right summary card header instead of on a separate row
|
||||
- summary and section labels use widget-title styling:
|
||||
- `11px`
|
||||
- `500`
|
||||
- uppercase
|
||||
- `letter-spacing: 0.8px`
|
||||
- `var(--text-secondary)`
|
||||
- line items live in one full-width card with sticky table header and total footer
|
||||
- notes card sits below line items when notes exist
|
||||
|
||||
### 19.4) Team -> Subcontractor PO Flow
|
||||
- Team creates purchase order for subcontractor work.
|
||||
- PO can optionally be tied to a project and task-backed line items.
|
||||
- PO email sending is currently disabled.
|
||||
- Subcontractor can approve the PO from their portal view.
|
||||
- PO approval is separate from subcontractor invoice payment.
|
||||
- A PO being approved does not itself mean an invoice has been paid.
|
||||
|
||||
### 19.5) Simple Mental Model
|
||||
- Fourge -> Client = invoice out
|
||||
- Client -> Fourge = payment in
|
||||
- Subcontractor -> Fourge = invoice in
|
||||
- Fourge -> Subcontractor = payment out
|
||||
- Fourge -> Subcontractor before payment = PO / work authorization
|
||||
|
||||
@@ -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); });
|
||||
@@ -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); });
|
||||
@@ -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); });
|
||||
@@ -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 & 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>
|
||||
@@ -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
@@ -52,8 +52,8 @@ const TeamSubInvoiceDetail = lazy(() => import('./pages/team/TeamSubInvoiceDetai
|
||||
const SurveyMaker = lazy(() => import('./pages/SurveyMaker'));
|
||||
const BrandBook = lazy(() => import('./pages/BrandBook'));
|
||||
const Converters = lazy(() => import('./pages/Converters'));
|
||||
const FileSharing = lazy(() => import('./pages/FileSharing'));
|
||||
const TeamFourgePasswords = lazy(() => import('./pages/team/TeamFourgePasswords'));
|
||||
const TeamReports = lazy(() => import('./pages/team/TeamReports'));
|
||||
const ExternalMyPurchaseOrders = lazy(() => import('./pages/external/ExternalMyPurchaseOrders'));
|
||||
const ExternalMyInvoices = lazy(() => import('./pages/external/ExternalMyInvoices'));
|
||||
const ExternalMyInvoiceDetail = lazy(() => import('./pages/external/ExternalMyInvoiceDetail'));
|
||||
@@ -73,6 +73,16 @@ function RedirectToTask() {
|
||||
return <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() {
|
||||
const { id } = useParams();
|
||||
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="/requests" 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="/invoices/new" element={<ProtectedRoute role="team"><TeamCreateInvoice /></ProtectedRoute>} />
|
||||
<Route path="/finances" element={<ProtectedRoute role="team"><TeamInvoices /></ProtectedRoute>} />
|
||||
<Route path="/finances/new" element={<ProtectedRoute role="team"><TeamCreateInvoice /></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="/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="/brand-book" element={<ProtectedRoute role={['team', 'external']}><BrandBook /></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="/reports" element={<ProtectedRoute role="team"><TeamReports /></ProtectedRoute>} />
|
||||
<Route path="/server-status" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/assigned-requests" element={<Navigate to="/tasks" replace />} />
|
||||
<Route path="/my-purchase-orders" element={<ProtectedRoute role="external"><ExternalMyPurchaseOrders /></ProtectedRoute>} />
|
||||
<Route path="/my-projects-sub" element={<Navigate to="/tasks" replace />} />
|
||||
<Route path="/my-invoices-sub" element={<ProtectedRoute role="external"><ExternalMyInvoices /></ProtectedRoute>} />
|
||||
<Route path="/my-invoices-sub/new" element={<ProtectedRoute role="external"><ExternalMyInvoiceCreate /></ProtectedRoute>} />
|
||||
<Route path="/my-invoices-sub/:id" element={<ProtectedRoute role="external"><ExternalMyInvoiceDetail /></ProtectedRoute>} />
|
||||
<Route path="/subs-invoices" element={<ProtectedRoute role="external"><ExternalMyInvoices /></ProtectedRoute>} />
|
||||
<Route path="/subs-invoices/new" element={<ProtectedRoute role="external"><ExternalMyInvoiceCreate /></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/:id" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} />
|
||||
@@ -130,7 +145,8 @@ export default function App() {
|
||||
<Route path="/my-requests/:id" element={<RedirectToTask />} />
|
||||
<Route path="/my-projects" element={<Navigate to="/tasks" replace />} />
|
||||
<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-project" element={<Navigate to="/tasks" replace />} />
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { useId, useRef, useState } from 'react';
|
||||
|
||||
const MAX_FILES = 20;
|
||||
const MAX_SIZE_MB = 250;
|
||||
@@ -10,6 +10,7 @@ const formatSize = (bytes) => {
|
||||
};
|
||||
|
||||
export default function FileAttachment({ files, onChange }) {
|
||||
const inputId = useId();
|
||||
const [errors, setErrors] = useState([]);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const dragCounter = useRef(0);
|
||||
@@ -79,8 +80,8 @@ export default function FileAttachment({ files, onChange }) {
|
||||
transition: 'all 160ms', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<input type="file" multiple onChange={handleChange} style={{ display: 'none' }} id="req-file-upload" />
|
||||
<label htmlFor="req-file-upload" style={{ cursor: 'pointer', textTransform: 'none', letterSpacing: 'normal', fontWeight: 400 }}>
|
||||
<input type="file" multiple onChange={handleChange} style={{ display: 'none' }} id={inputId} />
|
||||
<label htmlFor={inputId} style={{ cursor: 'pointer', textTransform: 'none', letterSpacing: 'normal', fontWeight: 400 }}>
|
||||
<div style={{ fontSize: 20, marginBottom: 4 }}>{dragging ? '📂' : '📎'}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>
|
||||
{dragging
|
||||
|
||||
@@ -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,17 +15,26 @@ export default function FilterDropdown({ value, onChange, options }) {
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open]);
|
||||
const current = options.find(o => o.value === value);
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative' }}>
|
||||
<button className="btn btn-outline btn-sm" onClick={() => setOpen(o => !o)} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<div ref={ref} style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
|
||||
<button
|
||||
className="btn btn-outline"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
|
||||
aria-label="Filter"
|
||||
title="Filter"
|
||||
>
|
||||
<FilterIcon />
|
||||
{current?.label}
|
||||
</button>
|
||||
{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 => (
|
||||
<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}
|
||||
</button>
|
||||
))}
|
||||
|
||||
+55
-25
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import PageLoader from './PageLoader';
|
||||
import ProfileAvatar from './ProfileAvatar';
|
||||
import { NavLink, Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
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>,
|
||||
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>,
|
||||
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>,
|
||||
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>,
|
||||
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>,
|
||||
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>,
|
||||
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>,
|
||||
@@ -28,14 +29,14 @@ function TeamNav({ onNav }) {
|
||||
const primaryLinks = [
|
||||
{ to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
|
||||
{ to: '/tasks', label: 'Tasks', icon: ICONS.requests },
|
||||
{ to: '/invoices', label: 'Finances', icon: ICONS.invoices },
|
||||
{ to: '/file-sharing', label: 'File Sharing', icon: ICONS.fileSharing },
|
||||
{ to: '/finances', label: 'Finances', icon: ICONS.invoices },
|
||||
];
|
||||
|
||||
const utilityLinks = [
|
||||
{ to: '/survey-maker', label: 'Survey Maker', icon: ICONS.survey },
|
||||
{ to: '/brand-book', label: 'Brand Book Maker', icon: ICONS.brandBook },
|
||||
{ to: '/converters', label: 'Image Converter', icon: ICONS.converter },
|
||||
{ to: '/reports', label: 'Reports', icon: ICONS.reports },
|
||||
{ to: '/fourge-passwords', label: 'Fourge Passwords', icon: ICONS.passwords },
|
||||
];
|
||||
|
||||
@@ -73,8 +74,7 @@ function ClientNav({ onNav }) {
|
||||
const links = [
|
||||
{ to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
|
||||
{ to: '/tasks', label: 'Tasks', icon: ICONS.requests },
|
||||
{ to: '/file-sharing', label: 'File Sharing', icon: ICONS.fileSharing },
|
||||
{ to: '/my-invoices', label: 'Invoices', icon: ICONS.invoices },
|
||||
{ to: '/client-invoices', label: 'Invoices', icon: ICONS.invoices },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -94,8 +94,7 @@ function ExternalNav({ onNav }) {
|
||||
const links = [
|
||||
{ to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
|
||||
{ to: '/tasks', label: 'Tasks', icon: ICONS.requests },
|
||||
{ to: '/my-invoices-sub', label: 'Invoices', icon: ICONS.invoices },
|
||||
{ to: '/file-sharing', label: 'File Sharing', icon: ICONS.fileSharing },
|
||||
{ to: '/subs-invoices', label: 'Invoices', icon: ICONS.invoices },
|
||||
{ to: '/survey-maker', label: 'Survey Maker', icon: ICONS.survey },
|
||||
{ to: '/brand-book', label: 'Brand Book Maker', icon: ICONS.brandBook },
|
||||
{ to: '/converters', label: 'Image Converter', icon: ICONS.converter },
|
||||
@@ -127,7 +126,7 @@ function getInitialTheme() {
|
||||
return 'dark';
|
||||
}
|
||||
|
||||
export default function Layout({ children }) {
|
||||
export default function Layout({ children, loading = false }) {
|
||||
const { currentUser, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
@@ -144,20 +143,54 @@ export default function Layout({ children }) {
|
||||
const timeOfDay = hour < 12 ? 'morning' : hour < 17 ? 'afternoon' : 'evening';
|
||||
const firstName = currentUser?.name?.split(' ')[0] || '';
|
||||
const isProfileRoute = location.pathname === '/profile' || location.pathname.startsWith('/profile/');
|
||||
const isFileSharingRoute = location.pathname === '/file-sharing';
|
||||
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 isFinancesRoute = location.pathname === '/invoices' || location.pathname.startsWith('/invoices/') || location.pathname === '/sub-invoices' || location.pathname.startsWith('/sub-invoices/');
|
||||
const headerTitle = isProfileRoute ? 'Profile' : isFileSharingRoute ? 'File Sharing' : isFinancesRoute ? 'Finances' : isRequestsRoute && !isTaskDetailRoute ? 'Tasks & Projects' : !isTaskDetailRoute ? `Good ${timeOfDay}${firstName ? `, ${firstName}` : ''}` : null;
|
||||
const isFinancesRoute =
|
||||
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
|
||||
? 'Account details and security settings.'
|
||||
: isFileSharingRoute
|
||||
? 'Browse, share and manage files.'
|
||||
: isFinancesRoute
|
||||
? 'Invoices, expenses and subcontractor POs.'
|
||||
: isRequestsRoute && !isTaskDetailRoute
|
||||
: isFinancesRoute
|
||||
? 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
|
||||
? '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(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
@@ -217,7 +250,7 @@ export default function Layout({ children }) {
|
||||
|
||||
return (
|
||||
<div className="app-layout">
|
||||
{navigating && <PageLoader />}
|
||||
{(navigating || loading) && <PageLoader />}
|
||||
{menuOpen && <div className="sidebar-overlay" onClick={() => setMenuOpen(false)} />}
|
||||
|
||||
<aside className={`sidebar${menuOpen ? ' sidebar-open' : ''}`}>
|
||||
@@ -245,11 +278,11 @@ export default function Layout({ children }) {
|
||||
<main className="main-content">
|
||||
<div className="site-header">
|
||||
<div>
|
||||
{isTaskDetailRoute ? (
|
||||
{isTaskDetailRoute || isInvoiceDetailRoute ? (
|
||||
<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>
|
||||
<span style={{ fontSize: 24, fontWeight: 500, lineHeight: 1.2, letterSpacing: '-0.3px', position: 'relative', top: 2 }}>Tasks & Projects</span>
|
||||
<span style={{ fontSize: 24, fontWeight: 500, lineHeight: 1.2, letterSpacing: '-0.3px', position: 'relative', top: 2 }}>{detailBackLabel}</span>
|
||||
</Link>
|
||||
<div className="site-header-sub" style={{ paddingLeft: 25 }}>Return back to previous page</div>
|
||||
</div>
|
||||
@@ -336,10 +369,7 @@ export default function Layout({ children }) {
|
||||
|
||||
<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' }}>
|
||||
{currentUser?.avatar_url
|
||||
? <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>
|
||||
}
|
||||
<ProfileAvatar profile={currentUser} name={currentUser?.name} size={68} fontSize={20} />
|
||||
</button>
|
||||
{avatarOpen && (
|
||||
<div className="site-header-avatar-menu">
|
||||
|
||||
@@ -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
|
||||
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' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 modalInputStyle = { fontSize: 13, padding: '0 5px', textAlign: 'left', lineHeight: 1 };
|
||||
const modalTextAreaStyle = { ...modalInputStyle, minHeight: 100, padding: '8px 5px', lineHeight: 1.4 };
|
||||
const modalActionButtonStyle = { width: 132, justifyContent: 'center', flex: '0 0 132px' };
|
||||
const emptyForm = (companyId = '') => ({
|
||||
companyId,
|
||||
project: '',
|
||||
@@ -43,7 +44,11 @@ export default function RequestForm({
|
||||
initialValues = null,
|
||||
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 [files, setFiles] = useState([]);
|
||||
const [existingProjects, setExistingProjects] = useState([]);
|
||||
@@ -54,16 +59,17 @@ export default function RequestForm({
|
||||
const [signFamilies, setSignFamilies] = useState([]);
|
||||
const [signCount, setSignCount] = useState('');
|
||||
const [signs, setSigns] = useState([]);
|
||||
const [localError, setLocalError] = useState('');
|
||||
|
||||
const isBrandBook = form.serviceType === 'Brand Book';
|
||||
const usesSignFields = form.serviceType === 'Brand Book';
|
||||
|
||||
useEffect(() => {
|
||||
supabase.from('sign_families').select('name').order('sort_order').then(({ data }) => setSignFamilies((data || []).map(r => r.name)));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isBrandBook) { setSignCount(''); setSigns([]); }
|
||||
}, [isBrandBook]);
|
||||
if (!usesSignFields) { setSignCount(''); setSigns([]); }
|
||||
}, [usesSignFields]);
|
||||
|
||||
useEffect(() => {
|
||||
const n = Math.max(0, parseInt(signCount) || 0);
|
||||
@@ -113,9 +119,15 @@ export default function RequestForm({
|
||||
prevCompanyIdRef.current = companyId;
|
||||
}, [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 = [
|
||||
...existingProjects.map(p => p.name),
|
||||
@@ -143,18 +155,56 @@ export default function RequestForm({
|
||||
};
|
||||
|
||||
const showCompanySelect = companies.length > 1 || showRequester;
|
||||
const requesterOptions = [
|
||||
...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)` }] : []),
|
||||
...companyUsers.filter(u => u.id !== currentUser?.id),
|
||||
];
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
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(
|
||||
{
|
||||
...form,
|
||||
companyId,
|
||||
requestedBy: currentUser?.id || '',
|
||||
requestedBy,
|
||||
requestedByName,
|
||||
signCount: isBrandBook ? (parseInt(signCount) || null) : null,
|
||||
signs: isBrandBook ? signs : [],
|
||||
signCount: usesSignFields ? normalizedSignCount : null,
|
||||
signs: usesSignFields ? normalizedSigns : [],
|
||||
},
|
||||
files,
|
||||
existingProjects
|
||||
@@ -214,6 +264,24 @@ export default function RequestForm({
|
||||
</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 */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 12, alignItems: 'end', marginBottom: 16 }}>
|
||||
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||
@@ -226,7 +294,7 @@ export default function RequestForm({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{isBrandBook && (
|
||||
{usesSignFields && (
|
||||
<div className="form-group">
|
||||
<label style={modalLabelStyle}>Sign Count *</label>
|
||||
<input
|
||||
@@ -242,7 +310,7 @@ export default function RequestForm({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isBrandBook && signs.length > 0 && (
|
||||
{usesSignFields && signs.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
|
||||
{signs.map((sign, i) => (
|
||||
<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>
|
||||
<input
|
||||
type="text"
|
||||
name={`sign_info_${i + 1}`}
|
||||
placeholder="e.g. Main Entry, Drive Thru"
|
||||
value={sign.signName}
|
||||
onChange={e => setSign(sign.id, 'signName', e.target.value)}
|
||||
@@ -264,19 +333,19 @@ export default function RequestForm({
|
||||
)}
|
||||
|
||||
<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 />
|
||||
</div>
|
||||
|
||||
<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' }}>
|
||||
<button type="submit" className="btn btn-outline" disabled={saving}>
|
||||
<div className="action-buttons modal-action-row" style={{ justifyContent: 'flex-end' }}>
|
||||
<button type="submit" className="btn btn-outline" disabled={saving} style={modalActionButtonStyle}>
|
||||
{saving ? 'Submitting...' : submitLabel}
|
||||
</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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -42,15 +42,58 @@
|
||||
--overlay-scrim: rgba(0,0,0,0.58);
|
||||
--popup-shadow: 0 24px 64px rgba(0,0,0,0.5);
|
||||
--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;
|
||||
--success: #22c55e;
|
||||
--table-scroll-thumb: rgba(0,0,0,0.1);
|
||||
--table-scroll-thumb-hover: rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
@keyframes ui-fade-up {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
@keyframes ambient-grid-flow {
|
||||
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"] {
|
||||
@@ -75,6 +118,9 @@
|
||||
--overlay-scrim: rgba(255,255,255,0.72);
|
||||
--popup-shadow: 0 16px 42px rgba(0,0,0,0.18);
|
||||
--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-hover: rgba(0,0,0,0.2);
|
||||
}
|
||||
@@ -90,8 +136,6 @@
|
||||
}
|
||||
[data-theme="light"] input::placeholder,
|
||||
[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"] .badge-not_started { background: #f1f5f9; color: #64748b; border-color: #e2e8f0; }
|
||||
[data-theme="light"] .badge-in_progress { background: #eff6ff; color: #2563eb; border-color: #bfdbfe; }
|
||||
@@ -128,9 +172,63 @@ body {
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
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 */
|
||||
.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 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 { 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-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; }
|
||||
@@ -343,7 +440,6 @@ input.site-header-search[type="text"]:focus { border-color: var(--accent); }
|
||||
.site-header-avatar-btn:hover { opacity: 0.85; }
|
||||
[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 { 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:hover { color: #fff; }
|
||||
.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;
|
||||
}
|
||||
|
||||
/* File Sharing */
|
||||
.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 {
|
||||
.shared-progress-bar {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
transition: width 0.2s ease;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.file-browser-progress-bar.indeterminate {
|
||||
.shared-progress-bar.indeterminate {
|
||||
width: 40% !important;
|
||||
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%); }
|
||||
}
|
||||
|
||||
.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 {
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -932,75 +839,6 @@ input.site-header-search[type="text"]:focus { border-color: var(--accent); }
|
||||
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 */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
@@ -1133,11 +971,10 @@ textarea {
|
||||
.table-wrapper::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--table-scroll-thumb-hover);
|
||||
}
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
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); }
|
||||
td { padding: 14px 16px; border-bottom: 1px solid var(--border); font-size: 13px; color: var(--text-primary); }
|
||||
table { width: 100%; border-collapse: collapse; table-layout: fixed; }
|
||||
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: 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; }
|
||||
[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; }
|
||||
@@ -1149,6 +986,17 @@ tr:hover td { background: rgba(255,255,255,0.02); }
|
||||
cursor: pointer;
|
||||
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:focus-visible {
|
||||
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; }
|
||||
|
||||
/* Auth */
|
||||
.auth-page { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #0d0d0d; padding: 20px; }
|
||||
.auth-card { background: #1a1a1a; border-radius: 4px; border: 1px solid var(--border); padding: 40px; width: 100%; max-width: 440px; }
|
||||
.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: 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 h1 { font-size: 22px; font-weight: 400; color: #ffffff; letter-spacing: -0.5px; }
|
||||
.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 select,
|
||||
.auth-card textarea {
|
||||
background: #222;
|
||||
border-color: #333;
|
||||
color: #fff;
|
||||
background: var(--card-bg-2);
|
||||
border-color: var(--border);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.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 .btn-outline { border-color: #333; color: #888; }
|
||||
.auth-card .btn-outline:hover { background: #222; color: #fff; }
|
||||
.auth-divider { display: flex; align-items: center; gap: 12px; margin: 20px 0; color: #444; font-size: 12px; }
|
||||
.auth-card .btn-outline { border-color: var(--border); color: var(--text-secondary); }
|
||||
.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: var(--text-muted); font-size: 12px; }
|
||||
.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-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-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:hover { background: #2a2a2a; }
|
||||
.quick-login-name { font-size: 13px; font-weight: 400; color: #fff; }
|
||||
.quick-login-email { font-size: 11px; color: #666; }
|
||||
.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: color-mix(in srgb, var(--card-bg-2) 72%, var(--accent) 28%); }
|
||||
.quick-login-name { font-size: 13px; font-weight: 400; color: var(--text-primary); }
|
||||
.quick-login-email { font-size: 11px; color: var(--text-muted); }
|
||||
|
||||
/* 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; }
|
||||
@@ -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-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; }
|
||||
.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-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); }
|
||||
@@ -1475,6 +1421,20 @@ select option { background: #222; color: #fff; }
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.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 option { background: #222; }
|
||||
.flex { display: flex; }
|
||||
@@ -1649,6 +1609,8 @@ select option { background: #222; color: #fff; }
|
||||
|
||||
/* Stack grids on mobile */
|
||||
.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; }
|
||||
.dash-stat-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) */
|
||||
.section-tab-btn {
|
||||
font-family: 'Fourge', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.2px;
|
||||
@@ -1713,22 +1677,26 @@ select option { background: #222; color: #fff; }
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
box-shadow: inset 0 -2px 0 transparent;
|
||||
transition: all 160ms;
|
||||
}
|
||||
.section-tab-btn:hover,
|
||||
.section-tab-btn:focus-visible {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
box-shadow: inset 0 -2px 0 var(--accent);
|
||||
outline: none;
|
||||
}
|
||||
button.section-tab-btn:hover,
|
||||
button.section-tab-btn:focus-visible {
|
||||
color: var(--accent) !important;
|
||||
border-bottom-color: var(--accent) !important;
|
||||
box-shadow: inset 0 -2px 0 var(--accent) !important;
|
||||
}
|
||||
.section-tab-btn.is-active {
|
||||
color: var(--text-primary);
|
||||
border-bottom-color: var(--accent);
|
||||
box-shadow: inset 0 -2px 0 var(--accent);
|
||||
}
|
||||
|
||||
/* Rebuilt hover system (single source of truth) */
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import jsPDF from 'jspdf';
|
||||
import { formatLongDate } from './dates';
|
||||
|
||||
function formatDate(dateStr) {
|
||||
return formatLongDate(dateStr ? `${dateStr}T12:00:00` : '', '-');
|
||||
}
|
||||
|
||||
// Letter landscape: 792 x 612 pt
|
||||
const W = 792;
|
||||
@@ -8,12 +13,6 @@ const ACCENT = [245, 165, 35];
|
||||
const DARK = [18, 18, 18];
|
||||
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) {
|
||||
const parts = String(address || '')
|
||||
.split(',')
|
||||
@@ -384,7 +383,7 @@ export async function generateBrandBookEditorPDF(data) {
|
||||
let pageNum = 0;
|
||||
const rev = String(revision || '01').padStart(2, '0');
|
||||
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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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) {
|
||||
if (!value) return null;
|
||||
const [year, month, day] = String(value).split('-').map(Number);
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,9 +1,18 @@
|
||||
import { supabase } from './supabase';
|
||||
import { ensureProjectFolder } from './folderSync';
|
||||
|
||||
function normalizeProjectName(name = '') {
|
||||
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 = []) {
|
||||
const normalized = normalizeProjectName(projectName);
|
||||
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')
|
||||
.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') {
|
||||
const { data: retryProjects, error: retryError } = await supabase
|
||||
@@ -88,6 +104,20 @@ export async function createInitialSubmissionForRequest({
|
||||
submittedBy,
|
||||
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
|
||||
.from('submissions')
|
||||
.insert({
|
||||
@@ -98,7 +128,8 @@ export async function createInitialSubmissionForRequest({
|
||||
is_hot: isHot,
|
||||
service_type: serviceType,
|
||||
sign_family: signFamily || null,
|
||||
sign_count: signCount || null,
|
||||
sign_count: normalizedSignCount || null,
|
||||
signs: serializeSubmissionSigns(normalizedSigns),
|
||||
deadline: deadline || null,
|
||||
description,
|
||||
submitted_by: submittedBy,
|
||||
@@ -107,19 +138,7 @@ export async function createInitialSubmissionForRequest({
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (!error && submission) {
|
||||
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 && submission) return { submission, duplicate: false };
|
||||
|
||||
if (error?.code === '23505' && requestKey) {
|
||||
const { data: existingSubmission, error: existingError } = await supabase
|
||||
@@ -128,7 +147,17 @@ export async function createInitialSubmissionForRequest({
|
||||
.eq('request_key', requestKey)
|
||||
.single();
|
||||
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.');
|
||||
|
||||
@@ -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 || '';
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import jsPDF from 'jspdf';
|
||||
import { formatDateAtNoon } from './dates';
|
||||
|
||||
const W = 792;
|
||||
const H = 612;
|
||||
@@ -8,9 +9,7 @@ const DARK = [18, 18, 18];
|
||||
const HEADER_H = 64;
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
const d = new Date(`${dateStr}T12:00:00`);
|
||||
return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
|
||||
return formatDateAtNoon(dateStr, '-');
|
||||
}
|
||||
|
||||
async function blobToDataUrl(blob) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -793,10 +793,11 @@ export default function BrandBook() {
|
||||
{loadingBooks ? (
|
||||
<p style={{ padding: '24px 0', color: 'var(--text-muted)' }}>Loading...</p>
|
||||
) : filteredBooks.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>{savedBooks.length === 0 ? 'No brand books yet' : 'No matching brand books'}</h3>
|
||||
<p>{savedBooks.length === 0 ? 'Create your first brand book to get started.' : 'Try clearing the current company filter.'}</p>
|
||||
<button className="btn btn-primary" onClick={handleNew} style={{ marginTop: 16 }}>+ New Brand Book</button>
|
||||
<div className="card" style={{ minHeight: 160, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12 }}>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)', textAlign: 'center' }}>
|
||||
{savedBooks.length === 0 ? 'No brand books' : 'No matching brand books'}
|
||||
</div>
|
||||
{savedBooks.length === 0 && <button className="btn btn-primary" onClick={handleNew}>+ New Brand Book</button>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
|
||||
+41
-182
@@ -1,13 +1,14 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import Layout from '../components/Layout';
|
||||
import PageLoader from '../components/PageLoader';
|
||||
import SortTh from '../components/SortTh';
|
||||
import { useSortable } from '../hooks/useSortable';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { deleteCompanyData } from '../lib/deleteHelpers';
|
||||
import { readPageCache, writePageCache } from '../lib/pageCache';
|
||||
import { createClientFolder, backfillClientFolders, createTeamMemberFolder } from '../lib/filebrowserFolders';
|
||||
import { ensureCompanyFolder, ensureSubcontractorFolder, renameSubcontractorFolder } from '../lib/folderSync';
|
||||
|
||||
// ── Team view ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -63,8 +64,11 @@ function TeamCompanies() {
|
||||
}).select().single();
|
||||
setSaving(false);
|
||||
if (data) {
|
||||
createClientFolder(data.name).catch(() => {});
|
||||
backfillClientFolders().catch(() => {});
|
||||
try {
|
||||
await ensureCompanyFolder({ companyId: data.id });
|
||||
} catch (folderError) {
|
||||
console.error('Company folder sync failed:', folderError);
|
||||
}
|
||||
setShowNew(false);
|
||||
setNewForm({ name: '', phone: '', address: '' });
|
||||
navigate(`/company/${data.id}`);
|
||||
@@ -80,8 +84,18 @@ function TeamCompanies() {
|
||||
|
||||
const handleEditUserSave = async (userId) => {
|
||||
if (!editUserVal.trim()) return;
|
||||
await supabase.from('profiles').update({ name: editUserVal.trim() }).eq('id', userId);
|
||||
setProfiles(prev => prev.map(u => u.id === userId ? { ...u, name: editUserVal.trim() } : u));
|
||||
const existingUser = profiles.find(u => u.id === userId);
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -113,15 +127,31 @@ function TeamCompanies() {
|
||||
const errBody = error?.context ? await error.context.json().catch(() => null) : null;
|
||||
const errMsg = errBody?.error || data?.error || error?.message;
|
||||
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') {
|
||||
createTeamMemberFolder(userForm.name.trim()).catch(() => {});
|
||||
}
|
||||
setShowNewUser(false);
|
||||
setUserForm({ name: '', email: '', password: '', company_id: '', role: 'client' });
|
||||
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 ids = new Set(
|
||||
@@ -209,7 +239,7 @@ function TeamCompanies() {
|
||||
)}
|
||||
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
{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' }}>
|
||||
<table>
|
||||
@@ -363,7 +393,7 @@ function TeamCompanies() {
|
||||
</div>
|
||||
)}
|
||||
{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' }}>
|
||||
<table>
|
||||
@@ -417,7 +447,7 @@ function TeamCompanies() {
|
||||
|
||||
{userSubTab === 'external' && <>
|
||||
{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' }}>
|
||||
<table>
|
||||
@@ -484,7 +514,7 @@ function ClientCompanyList() {
|
||||
</div>
|
||||
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
{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' }}>
|
||||
<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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
export default function CompaniesPage() {
|
||||
|
||||
+21
-14
@@ -1,13 +1,14 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import Layout from '../components/Layout';
|
||||
import PageLoader from '../components/PageLoader';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { serviceTypes } from '../data/mockData';
|
||||
import { cleanupTaskStorage, deleteCompanyData } from '../lib/deleteHelpers';
|
||||
import { logActivity } from '../lib/activityLog';
|
||||
import { createProjectFolder } from '../lib/filebrowserFolders';
|
||||
import { ensureProjectFolder, renameCompanyFolder } from '../lib/folderSync';
|
||||
|
||||
export default function CompanyDetail() {
|
||||
const { id } = useParams();
|
||||
@@ -78,8 +79,14 @@ export default function CompanyDetail() {
|
||||
if (!nameVal.trim()) return;
|
||||
setSavingName(true);
|
||||
const oldName = company.name;
|
||||
await supabase.from('companies').update({ name: nameVal.trim() }).eq('id', id);
|
||||
setCompany(c => ({ ...c, name: nameVal.trim() }));
|
||||
const newName = 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);
|
||||
setSavingName(false);
|
||||
};
|
||||
@@ -179,8 +186,12 @@ export default function CompanyDetail() {
|
||||
status: 'active',
|
||||
}).select().single();
|
||||
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 });
|
||||
createProjectFolder(company.name, data.name).catch(() => {});
|
||||
setProjects(prev => [data, ...prev]);
|
||||
setNewProjectName('');
|
||||
setShowNewProject(false);
|
||||
@@ -248,7 +259,7 @@ export default function CompanyDetail() {
|
||||
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>;
|
||||
|
||||
const activeTasks = tasks.filter(t => t.status !== 'client_approved');
|
||||
@@ -318,15 +329,14 @@ export default function CompanyDetail() {
|
||||
</div>
|
||||
|
||||
{/* 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 => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`tab-btn${tab === t ? ' active' : ''}`}
|
||||
style={{ textTransform: 'capitalize' }}
|
||||
className={`section-tab-btn${tab === t ? ' is-active' : ''}`}
|
||||
>
|
||||
{t}
|
||||
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||
{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 }}>
|
||||
{availableUsers.length}
|
||||
@@ -342,7 +352,7 @@ export default function CompanyDetail() {
|
||||
<div className="card">
|
||||
<div className="card-title">Assigned Users</div>
|
||||
{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 }}>
|
||||
{users.map((user, i) => (
|
||||
@@ -494,10 +504,7 @@ export default function CompanyDetail() {
|
||||
)}
|
||||
|
||||
{projects.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>No projects yet</h3>
|
||||
<p>Create a project to start adding jobs.</p>
|
||||
</div>
|
||||
<div className="card card-empty-center">No projects</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{projects.map(project => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import JSZip from 'jszip';
|
||||
import { heicTo, isHeic } from 'heic-to/csp';
|
||||
import Layout from '../components/Layout';
|
||||
import LoadingButton from '../components/LoadingButton';
|
||||
|
||||
@@ -13,6 +12,14 @@ const OUTPUT_FORMATS = [
|
||||
const HEIC_EXTENSIONS = new Set(['heic', 'heif']);
|
||||
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 = '') {
|
||||
return name.split('.').pop()?.toLowerCase() || '';
|
||||
}
|
||||
@@ -96,6 +103,7 @@ async function convertRasterBlob(blob, format, quality) {
|
||||
}
|
||||
|
||||
async function convertHeicFile(file, format, quality) {
|
||||
const { heicTo } = await getHeicLib();
|
||||
if (format.mime === 'image/jpeg' || format.mime === 'image/png') {
|
||||
try {
|
||||
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) {
|
||||
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) {
|
||||
return convertHeicFile(file, format, quality);
|
||||
}
|
||||
@@ -368,7 +378,7 @@ export default function Converters() {
|
||||
</div>
|
||||
|
||||
{!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 }}>
|
||||
{files.map((file, index) => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useSearchParams } from 'react-router-dom';
|
||||
import PageLoader from '../components/PageLoader';
|
||||
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
||||
@@ -74,15 +75,14 @@ export default function PayInvoice() {
|
||||
|
||||
return (
|
||||
<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 }}>
|
||||
{/* Logo */}
|
||||
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||
<img src="/fourge-logo.png" alt="Fourge Branding" style={{ height: 36, filter: 'invert(1)' }} />
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', color: '#666' }}>Loading...</div>
|
||||
) : !invoice ? (
|
||||
{!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={{ fontSize: 18, fontWeight: 400, marginBottom: 8 }}>Invoice not found</div>
|
||||
<div style={{ color: '#666' }}>This payment link may be invalid or expired.</div>
|
||||
|
||||
@@ -689,7 +689,7 @@ export default function ProfilePage() {
|
||||
<input type="text" value={editForm.linkedin} onChange={setEditField('linkedin')} placeholder="linkedin.com/in/username" style={modalInputStyle} />
|
||||
</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}>
|
||||
{savingProfile ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
|
||||
+108
-166
@@ -1,34 +1,35 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import Layout from '../components/Layout';
|
||||
import PageLoader from '../components/PageLoader';
|
||||
import ProfileAvatar from '../components/ProfileAvatar';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import SortTh from '../components/SortTh';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { logActivity } from '../lib/activityLog';
|
||||
import { createTaskFolder, uploadFilesToRequestInfo } from '../lib/filebrowserFolders';
|
||||
import { createInitialSubmissionForRequest, createTaskForRequest } from '../lib/requestSubmission';
|
||||
import { ensureRequestFolder, renameProjectFolder, uploadRequestFileToSurvey } from '../lib/folderSync';
|
||||
import { sendEmail } from '../lib/email';
|
||||
import RequestForm from '../components/RequestForm';
|
||||
import { useSortable } from '../hooks/useSortable';
|
||||
import { serviceTypes } from '../data/mockData';
|
||||
import { addDaysToDateOnly, getTodayDateOnlyEST, fmtShortDate } from '../lib/dates';
|
||||
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
|
||||
import { useRefetchOnFocus } from '../hooks/useRefetchOnFocus';
|
||||
import { useRealtimeSubscription } from '../hooks/useRealtimeSubscription';
|
||||
import { fmtShortDate } from '../lib/dates';
|
||||
import { popupOverlayStyle } from '../lib/popupStyles';
|
||||
import { useLiveRefresh } from '../hooks/useLiveRefresh';
|
||||
import { getTaskDerivedState } from '../lib/taskVersions';
|
||||
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 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 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 emptyJobForm = () => ({ title: '', serviceType: '', deadline: addDaysToDateOnly(getTodayDateOnlyEST(), 3), description: '', requestedBy: '' });
|
||||
|
||||
export default function ProjectDetailPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
@@ -38,28 +39,22 @@ export default function ProjectDetailPage() {
|
||||
const isExternal = currentUser?.role === 'external';
|
||||
const isTeam = currentUser?.role === 'team';
|
||||
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const refresh = useCallback(() => setRefreshKey(k => k + 1), []);
|
||||
useRefetchOnFocus(refresh);
|
||||
useRealtimeSubscription(['tasks', 'projects', 'project_members', 'profiles'], refresh);
|
||||
const { refreshKey } = useLiveRefresh(['tasks', 'projects', 'project_members', 'profiles']);
|
||||
|
||||
const [project, setProject] = useState(null);
|
||||
const [company, setCompany] = useState(null);
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [members, setMembers] = useState([]);
|
||||
const [externalProfiles, setExtProfs] = useState([]);
|
||||
const [companyUsers, setCompanyUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submissions, setSubmissions] = useState([]);
|
||||
const [deliveries, setDeliveries] = useState([]);
|
||||
const [activeTab, setActiveTab] = useState('all');
|
||||
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
const [nameVal, setNameVal] = useState('');
|
||||
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 [clientTaskKey, setClientTaskKey] = useState(0);
|
||||
const [clientTaskSaving, setClientTaskSaving] = useState(false);
|
||||
@@ -93,13 +88,20 @@ export default function ProjectDetailPage() {
|
||||
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 });
|
||||
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([
|
||||
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('company_members').select('profile:profiles(id, name, avatar_url, email, role)').eq('company_id', p.company_id),
|
||||
]);
|
||||
setCompanyUsers(users || []);
|
||||
setMembers(pm || []);
|
||||
const seen = new Set();
|
||||
const merged = [];
|
||||
@@ -128,8 +130,15 @@ export default function ProjectDetailPage() {
|
||||
e.preventDefault();
|
||||
if (!nameVal.trim()) return;
|
||||
setSavingName(true);
|
||||
await supabase.from('projects').update({ name: nameVal.trim() }).eq('id', id);
|
||||
setProject(p => ({ ...p, name: nameVal.trim() }));
|
||||
const oldName = project.name;
|
||||
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);
|
||||
setSavingName(false);
|
||||
};
|
||||
@@ -151,23 +160,6 @@ export default function ProjectDetailPage() {
|
||||
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 () => {
|
||||
setSavingMembers(true);
|
||||
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);
|
||||
};
|
||||
|
||||
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) => {
|
||||
if (clientTaskSaving) return;
|
||||
setClientTaskSaving(true); setClientTaskError('');
|
||||
try {
|
||||
const { task } = await createTaskForRequest({ projectId: project.id, title: formData.title.trim(), requestKey: clientTaskRequestKey });
|
||||
if (!task) throw new Error('Failed to create task.');
|
||||
createTaskFolder(company?.name, project.name, formData.title.trim()).catch(() => {});
|
||||
const { submission } = await createInitialSubmissionForRequest({
|
||||
taskId: task.id, requestKey: clientTaskRequestKey, isHot: formData.isHot,
|
||||
serviceType: formData.serviceType, signFamily: formData.signFamily,
|
||||
@@ -207,16 +193,32 @@ export default function ProjectDetailPage() {
|
||||
deadline: formData.deadline, description: formData.description,
|
||||
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) {
|
||||
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);
|
||||
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(() => {});
|
||||
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 });
|
||||
setTasks(newTasks || []);
|
||||
setShowClientTask(false); setClientTaskKey(k => k + 1); setClientTaskRequestKey(crypto.randomUUID());
|
||||
@@ -227,19 +229,14 @@ export default function ProjectDetailPage() {
|
||||
if (loading) return <Layout><PageLoader /></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 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 {
|
||||
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,
|
||||
serviceType: sub?.service_type || '—', deadline: sub?.deadline || null, isHot: sub?.is_hot || false,
|
||||
submittedAt: task.submitted_at || '',
|
||||
serviceType: derived.serviceType, deadline: derived.deadline, isHot: derived.isHot,
|
||||
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 taskTabs = [
|
||||
{ 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: 'on_hold', label: 'On Hold' },
|
||||
{ id: 'client_review', label: 'In Review' },
|
||||
@@ -267,6 +265,8 @@ export default function ProjectDetailPage() {
|
||||
];
|
||||
const visibleRows = activeTab === 'all' ? sortedRows
|
||||
: activeTab === 'completed' ? sortedRows.filter(r => doneStatuses.has(r.status))
|
||||
: activeTab === 'new_requests' ? sortedRows.filter(r => r.status === 'not_started' && Number(r.version || 0) === 0)
|
||||
: activeTab === 'revisions' ? sortedRows.filter(r => r.status === 'not_started' && Number(r.version || 0) >= 1)
|
||||
: activeTab === 'members' ? []
|
||||
: sortedRows.filter(r => r.status === activeTab);
|
||||
|
||||
@@ -345,12 +345,13 @@ export default function ProjectDetailPage() {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
|
||||
{(() => {
|
||||
const tabCounts = {
|
||||
all: tasks.length,
|
||||
not_started: tasks.filter(t => t.status === 'not_started').length,
|
||||
in_progress: tasks.filter(t => t.status === 'in_progress').length,
|
||||
on_hold: tasks.filter(t => t.status === 'on_hold').length,
|
||||
client_review: tasks.filter(t => t.status === 'client_review').length,
|
||||
completed: tasks.filter(t => ['client_approved','invoiced','paid'].includes(t.status)).length,
|
||||
all: rows.length,
|
||||
new_requests: rows.filter(r => r.status === 'not_started' && Number(r.version || 0) === 0).length,
|
||||
revisions: rows.filter(r => r.status === 'not_started' && Number(r.version || 0) >= 1).length,
|
||||
in_progress: rows.filter(r => r.status === 'in_progress').length,
|
||||
on_hold: rows.filter(r => r.status === 'on_hold').length,
|
||||
client_review: rows.filter(r => r.status === 'client_review').length,
|
||||
completed: rows.filter(r => ['client_approved','invoiced','paid'].includes(r.status)).length,
|
||||
members: members.filter(m => m.profile?.role === 'external').length,
|
||||
};
|
||||
return (
|
||||
@@ -375,7 +376,7 @@ export default function ProjectDetailPage() {
|
||||
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
{activeTab !== 'members' && (
|
||||
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' }}>
|
||||
<table className="table-sticky-head" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
|
||||
<colgroup>
|
||||
@@ -389,44 +390,40 @@ export default function ProjectDetailPage() {
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="revision" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TH_STYLE}>R#</SortTh>
|
||||
<SortTh col="title" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TH_STYLE}>Name</SortTh>
|
||||
<SortTh col="assigned" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TH_STYLE, textAlign: 'center' }}>Assigned</SortTh>
|
||||
<SortTh col="priority" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...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="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TH_STYLE, textAlign: 'center' }}>Deadline</SortTh>
|
||||
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TH_STYLE, textAlign: 'center' }}>Status</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={TASK_TABLE_TH_STYLE}>Name</SortTh>
|
||||
<SortTh col="assigned" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Assigned</SortTh>
|
||||
<SortTh col="priority" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Priority</SortTh>
|
||||
<SortTh col="serviceType" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Task Type</SortTh>
|
||||
<SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Deadline</SortTh>
|
||||
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Status</SortTh>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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 };
|
||||
return (
|
||||
<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>
|
||||
</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>
|
||||
</td>
|
||||
<td style={{ ...TD_BASE, textAlign: 'center' }}>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, textAlign: 'center' }}>
|
||||
{row.assignedName && row.assignedTo
|
||||
? <Link to={`/profile/${row.assignedTo}`} style={{ display: 'inline-flex' }}>
|
||||
{row.assigneeAvatar
|
||||
? <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>
|
||||
}
|
||||
<ProfileAvatar name={row.assignedName} avatarUrl={row.assigneeAvatar} size={26} fontSize={10} style={{ verticalAlign: 'middle' }} />
|
||||
</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>
|
||||
}
|
||||
</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'}
|
||||
</td>
|
||||
<td style={{ ...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={{ ...TD_BASE, textAlign: 'center' }}><StatusBadge status={row.status} /></td>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>{row.serviceType}</td>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>{fmtShortDate(row.deadline, 'Not specified')}</td>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, textAlign: 'center' }}><StatusBadge status={row.status} /></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
@@ -439,7 +436,7 @@ export default function ProjectDetailPage() {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{(() => {
|
||||
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) => {
|
||||
if (subSortKey === 'projects') {
|
||||
const av = subProjectCounts[a.profile_id] || 0;
|
||||
@@ -461,29 +458,25 @@ export default function ProjectDetailPage() {
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ ...TH_STYLE, padding: '0 0 12px' }} />
|
||||
<SortTh col="name" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={TH_STYLE}>Name</SortTh>
|
||||
<SortTh col="email" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={TH_STYLE}>Email</SortTh>
|
||||
<SortTh col="projects" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={{ ...TH_STYLE, textAlign: 'center' }}>Projects</SortTh>
|
||||
<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={TASK_TABLE_TH_STYLE}>Name</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={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Projects</SortTh>
|
||||
<th style={{ ...TASK_TABLE_TH_STYLE, padding: '0 0 12px' }} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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;
|
||||
return (
|
||||
<tr key={m.id}>
|
||||
<td style={{ ...TD_BASE }}>
|
||||
{m.profile?.avatar_url
|
||||
? <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 style={{ ...TASK_TABLE_TD_BASE }}>
|
||||
<ProfileAvatar profile={m.profile} name={m.profile?.name} size={26} fontSize={10} />
|
||||
</td>
|
||||
<td style={{ ...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={{ ...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, fontSize: 13, color: 'var(--text-primary)' }}>{m.profile?.name || '—'}</td>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-muted)' }}>{m.profile?.email || '—'}</td>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)', textAlign: 'center', fontVariantNumeric: 'tabular-nums' }}>{projCount}</td>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE }} />
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
@@ -502,17 +495,13 @@ export default function ProjectDetailPage() {
|
||||
<div className="card" style={CARD}>
|
||||
<div style={LABEL}>Main Contact</div>
|
||||
{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 }}>
|
||||
{companyClients.map(person => {
|
||||
const initials = (person.name || '?').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
|
||||
return (
|
||||
<div key={person.id} style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
{person.avatar_url
|
||||
? <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>
|
||||
}
|
||||
<ProfileAvatar profile={person} name={person.name} size={32} fontSize={12} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<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>}
|
||||
@@ -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 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 (
|
||||
<div className="scrollbar-thin-theme" style={{ flex: 1, minHeight: 0, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{filtered.map(e => {
|
||||
@@ -561,51 +550,10 @@ export default function ProjectDetailPage() {
|
||||
</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 && (
|
||||
<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={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 14 }}>New Task</div>
|
||||
<div style={TASK_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ ...TASK_MODAL_TITLE_STYLE, marginBottom: 14 }}>New Task</div>
|
||||
<RequestForm
|
||||
key={clientTaskKey}
|
||||
companies={company ? [company] : []}
|
||||
@@ -626,14 +574,14 @@ export default function ProjectDetailPage() {
|
||||
|
||||
{addingMember && isTeam && (
|
||||
<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={{ 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>
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '0 21px' }}>
|
||||
{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%' }}>
|
||||
<colgroup>
|
||||
<col style={{ width: '5%' }} />
|
||||
@@ -643,10 +591,10 @@ export default function ProjectDetailPage() {
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ ...TH_STYLE, padding: '0 0 12px' }} />
|
||||
<SortTh col="name" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={TH_STYLE}>Name</SortTh>
|
||||
<SortTh col="email" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={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' }} />
|
||||
<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={TASK_TABLE_TH_STYLE}>Email</SortTh>
|
||||
<th style={{ ...TASK_TABLE_TH_STYLE, padding: '0 0 12px', textAlign: 'center' }}>Assigned</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -656,16 +604,10 @@ export default function ProjectDetailPage() {
|
||||
return subSortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
|
||||
}).map(p => {
|
||||
const checked = selectedExts.has(p.id);
|
||||
const initials = (p.name || '?').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
|
||||
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' }}>
|
||||
<td style={{ padding: '5px 0', border: 'none', background: 'transparent' }}>
|
||||
{p.avatar_url
|
||||
? <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>
|
||||
}
|
||||
<ProfileAvatar profile={p} name={p.name} size={26} fontSize={10} />
|
||||
</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>
|
||||
@@ -681,7 +623,7 @@ export default function ProjectDetailPage() {
|
||||
</table>
|
||||
}
|
||||
</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" onClick={() => { setAddingMember(false); setSelectedExts(new Set()); }}>Cancel</button>
|
||||
</div>
|
||||
|
||||
+374
-163
@@ -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 Layout from '../components/Layout';
|
||||
import PageLoader from '../components/PageLoader';
|
||||
import FileBrowser from '../components/FileBrowser';
|
||||
import ProfileAvatar from '../components/ProfileAvatar';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { logActivity } from '../lib/activityLog';
|
||||
import JSZip from 'jszip';
|
||||
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
|
||||
import { sendEmail } from '../lib/email';
|
||||
import { uploadFilesToRequestInfo, safeName } from '../lib/filebrowserFolders';
|
||||
import { useRefetchOnFocus } from '../hooks/useRefetchOnFocus';
|
||||
import { useRealtimeSubscription } from '../hooks/useRealtimeSubscription';
|
||||
import { useLiveRefresh } from '../hooks/useLiveRefresh';
|
||||
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 = {
|
||||
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 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 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 }) {
|
||||
const status = task?.status;
|
||||
@@ -135,19 +152,7 @@ function TimelineCard({ task, activityLog, currentSubmission }) {
|
||||
}
|
||||
|
||||
function AssigneePortrait({ name, avatarUrl }) {
|
||||
if (avatarUrl) {
|
||||
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>
|
||||
);
|
||||
return <ProfileAvatar name={name} avatarUrl={avatarUrl} size={32} fontSize={12} />;
|
||||
}
|
||||
|
||||
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() {
|
||||
const { id } = useParams();
|
||||
const { currentUser } = useAuth();
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const refresh = useCallback(() => setRefreshKey(k => k + 1), []);
|
||||
useRefetchOnFocus(refresh);
|
||||
useRealtimeSubscription(['tasks', 'submissions', 'activity_log', 'task_comments'], refresh);
|
||||
const { refreshKey } = useLiveRefresh(['tasks', 'submissions', 'activity_log', 'task_comments']);
|
||||
const [task, setTask] = useState(null);
|
||||
const [submissions, setSubmissions] = useState([]);
|
||||
const [activityLog, setActivityLog] = useState([]);
|
||||
@@ -213,25 +260,55 @@ export default function TaskDetail() {
|
||||
const [revisionFiles, setRevisionFiles] = useState([]);
|
||||
const [revisionSaving, setRevisionSaving] = useState(false);
|
||||
const revisionFileRef = useRef(null);
|
||||
const [rejectModal, setRejectModal] = useState(false);
|
||||
const [rejectNote, setRejectNote] = useState('');
|
||||
const [rejectSaving, setRejectSaving] = useState(false);
|
||||
const [amendModal, setAmendModal] = useState(false);
|
||||
const [amendForm, setAmendForm] = useState({ description: '' });
|
||||
const [amendFiles, setAmendFiles] = useState([]);
|
||||
const [amendSaving, setAmendSaving] = useState(false);
|
||||
const [amendDragging, setAmendDragging] = useState(false);
|
||||
const amendDragCounter = useRef(0);
|
||||
const amendFileRef = useRef(null);
|
||||
const [reviewModal, setReviewModal] = useState(false);
|
||||
const [reviewFiles, setReviewFiles] = useState([]);
|
||||
const [reviewNotes, setReviewNotes] = useState('');
|
||||
const [reviewSaving, setReviewSaving] = useState(false);
|
||||
const [reviewDragging, setReviewDragging] = useState(false);
|
||||
const reviewDragCounter = useRef(0);
|
||||
const reviewFileRef = useRef(null);
|
||||
const [comments, setComments] = useState([]);
|
||||
const [commentBody, setCommentBody] = useState('');
|
||||
const [commentSaving, setCommentSaving] = useState(false);
|
||||
const [deliveries, setDeliveries] = useState([]);
|
||||
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(() => {
|
||||
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)')
|
||||
.eq('id', id)
|
||||
.single(),
|
||||
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, 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')
|
||||
@@ -256,7 +329,7 @@ export default function TaskDetail() {
|
||||
.select('id, author_id, author_name, body, created_at')
|
||||
.eq('task_id', id)
|
||||
.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);
|
||||
setSubmissions(subRows || []);
|
||||
setActivityLog(actData || []);
|
||||
@@ -277,76 +350,225 @@ export default function TaskDetail() {
|
||||
if (loading) return <Layout><PageLoader /></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 amendments = submissions.filter(s => s.type === 'amendment');
|
||||
const revisions = submissions.filter(s => s.type === 'revision').sort((a, b) => a.version_number - b.version_number);
|
||||
const visibleSubmissions = getVisibleTaskSubmissions(submissions);
|
||||
const submission = visibleSubmissions.find(s => s.type === 'initial') || visibleSubmissions[0] || null;
|
||||
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 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 assigneeName = assignee?.name || task.assigned_name || null;
|
||||
const assigneeAvatar = assignee?.avatar_url || null;
|
||||
const project = task.project;
|
||||
const company = project?.company;
|
||||
const requester = submission?.submitted_by_name || null;
|
||||
const requester = getSubmissionDisplayName(submission) || null;
|
||||
const role = currentUser?.role;
|
||||
const isClient = role === 'client';
|
||||
const isTeamOrSub = role === 'team' || role === 'external';
|
||||
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 updateStatus = async (newStatus, extra = {}, action = null) => {
|
||||
setStatusSaving(true);
|
||||
await supabase.from('tasks').update({ status: newStatus, ...extra }).eq('id', id);
|
||||
const prevTask = task;
|
||||
setTask(t => ({ ...t, status: newStatus, ...extra }));
|
||||
if (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);
|
||||
setActivityLog(data || []);
|
||||
try {
|
||||
const { error: taskUpdateError } = await supabase.from('tasks').update({ status: newStatus, ...extra }).eq('id', id);
|
||||
if (taskUpdateError) throw taskUpdateError;
|
||||
await reloadTaskRow();
|
||||
if (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);
|
||||
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 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 handleSendToReview = () => { setReviewFiles([]); setReviewNotes(''); setReviewModal(true); };
|
||||
const handleRemoveFromReview = () => updateStatus('in_progress', {}, 'task_resumed');
|
||||
const handleConfirmReview = async () => {
|
||||
setReviewSaving(true);
|
||||
try {
|
||||
const briefId = submissions.find(s => s.type === 'initial')?.id || submissions[0]?.id;
|
||||
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 targetVersion = task.current_version || 0;
|
||||
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) {
|
||||
const uploaded = [];
|
||||
for (const file of reviewFiles) {
|
||||
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 (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 });
|
||||
setDeliveries(delRows || []);
|
||||
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);
|
||||
setReviewFiles([]);
|
||||
setReviewNotes('');
|
||||
} catch (err) {
|
||||
console.error('Place in review failed:', err);
|
||||
window.alert(err?.message || 'Failed to place task in review.');
|
||||
} finally {
|
||||
setReviewSaving(false);
|
||||
}
|
||||
};
|
||||
const handleClientApprove = () => updateStatus('client_approved', { completed_at: new Date().toISOString() }, 'task_approved');
|
||||
const handleClientReject = () => updateStatus('not_started', { current_version: (task.current_version || 0) + 1, assigned_to: null, assigned_name: null }, 'revision_requested');
|
||||
const handleClientApprove = async () => {
|
||||
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 handleSubmitRevision = async () => {
|
||||
setRevisionSaving(true);
|
||||
const baseline = Math.max(task.current_version || 0, ...submissions.map(s => s.version_number || 0));
|
||||
const newVersion = baseline + 1;
|
||||
await supabase.from('tasks').update({ status: 'not_started', current_version: newVersion, assigned_to: null, assigned_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: task.title, deadline: revisionForm.deadline || null, description: revisionForm.description, submitted_by: currentUser.id, submitted_by_name: currentUser.name }).select().single();
|
||||
await supabase.from('tasks').update({
|
||||
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) {
|
||||
const uploadedFiles = [];
|
||||
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 (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 });
|
||||
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 }, { data: subRows }, { data: actData }] = await Promise.all([
|
||||
const [{ data: taskData }, 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('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),
|
||||
]);
|
||||
setTask(taskData);
|
||||
setSubmissions(subRows || []);
|
||||
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);
|
||||
setRevisionForm({ description: '', deadline: '' });
|
||||
setRevisionFiles([]);
|
||||
@@ -378,7 +609,7 @@ export default function TaskDetail() {
|
||||
setAmendSaving(true);
|
||||
setAmendModal(false);
|
||||
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) {
|
||||
setDlProgress({ active: true, label: 'Uploading files…', percent: 0 });
|
||||
const uploaded = [];
|
||||
@@ -390,11 +621,20 @@ export default function TaskDetail() {
|
||||
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 (project?.name && task?.title) uploadFilesToRequestInfo(amendFiles, company?.name, project.name, task.title, baseline).catch(() => {});
|
||||
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 || []);
|
||||
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: '' });
|
||||
setAmendFiles([]);
|
||||
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={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||
{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 && (
|
||||
<>
|
||||
<button className="btn btn-outline" disabled={statusSaving} onClick={handleRelease}>Release</button>
|
||||
<button className="btn btn-outline" disabled={statusSaving} onClick={handleOnHold}>Place on Hold</button>
|
||||
<button className="btn btn-primary" disabled={statusSaving} onClick={handleSendToReview}>Place in Review</button>
|
||||
<button className="btn btn-outline" disabled={actionBusy} onClick={handleRelease}>Release</button>
|
||||
<button className="btn btn-outline" disabled={actionBusy} onClick={handleOnHold}>Place on Hold</button>
|
||||
<button className="btn btn-primary" disabled={actionBusy} onClick={handleSendToReview}>Place in Review</button>
|
||||
</>
|
||||
)}
|
||||
{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 && (
|
||||
<>
|
||||
<button className="btn btn-outline" disabled={statusSaving} onClick={handleRelease}>Release</button>
|
||||
<button className="btn btn-outline" disabled={statusSaving} onClick={handleResume}>Resume</button>
|
||||
<button className="btn btn-outline" disabled={actionBusy} onClick={handleRelease}>Release</button>
|
||||
<button className="btn btn-outline" disabled={actionBusy} onClick={handleResume}>Resume</button>
|
||||
</>
|
||||
)}
|
||||
{isClient && status === 'client_review' && (
|
||||
<>
|
||||
<button className="btn btn-primary" disabled={statusSaving} onClick={handleClientApprove}>Approve</button>
|
||||
<button className="btn btn-danger" disabled={statusSaving} onClick={handleClientReject}>Reject</button>
|
||||
<button className="btn btn-primary" disabled={actionBusy} onClick={handleClientApprove}>Approve</button>
|
||||
<button className="btn btn-danger" disabled={actionBusy} onClick={handleClientReject}>Reject</button>
|
||||
</>
|
||||
)}
|
||||
{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 style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 20 }}>
|
||||
{submission?.service_type && (
|
||||
<span className="badge badge-status badge-client">{submission.service_type}</span>
|
||||
{canonicalTaskServiceType !== '—' && (
|
||||
<span className="badge badge-status badge-client">{canonicalTaskServiceType}</span>
|
||||
)}
|
||||
<StatusBadge status={task.status || 'not_started'} />
|
||||
</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>
|
||||
<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 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', alignItems: 'center', gap: 4, marginBottom: 10 }}>
|
||||
{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 (
|
||||
<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>}
|
||||
@@ -549,8 +789,8 @@ export default function TaskDetail() {
|
||||
);
|
||||
})}
|
||||
</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 style={{ fontSize: 13, ...(activeTab === 'Folder' ? { flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 } : {}) }}>
|
||||
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
<div style={{ fontSize: 13 }}>
|
||||
{activeTab === 'Overview' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
{rejectedNoteByVersion.get(0) && (
|
||||
<div style={{ marginLeft: 'auto', flex: '0 0 min(320px, 36%)' }}>
|
||||
<RejectedNoteBlock note={rejectedNoteByVersion.get(0)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{submission?.description && (
|
||||
<div>
|
||||
@@ -586,6 +831,7 @@ export default function TaskDetail() {
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{submission.sign_family}</div>
|
||||
</div>
|
||||
)}
|
||||
<SubmissionSigns signs={submission?.signs} signCount={submission?.sign_count} />
|
||||
<SubmissionFiles files={submission?.files} downloading={downloading} onDownloadAll={downloadAllFiles} />
|
||||
{amendments.length > 0 && (
|
||||
<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' && (
|
||||
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 }}>
|
||||
{[...revisions].reverse().map((rev, i, arr) => {
|
||||
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>
|
||||
<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 style={META_LABEL}>Requested</div>
|
||||
@@ -640,6 +886,11 @@ export default function TaskDetail() {
|
||||
<button className="btn btn-outline" onClick={() => setAmendModal(true)}>Amend</button>
|
||||
</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>
|
||||
{rev.description && (
|
||||
<div>
|
||||
@@ -653,7 +904,8 @@ export default function TaskDetail() {
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{rev.sign_family}</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>
|
||||
)}
|
||||
<SubmissionFiles files={rev.files} downloading={downloading} onDownloadAll={(f) => downloadAllFiles(f, rNum)} />
|
||||
@@ -664,7 +916,7 @@ export default function TaskDetail() {
|
||||
)}
|
||||
{activeTab === 'Submissions' && (
|
||||
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 }}>
|
||||
{reviewSubmissions.map((sub, i, arr) => {
|
||||
const fmtDate = sub.sent_at
|
||||
@@ -681,7 +933,7 @@ export default function TaskDetail() {
|
||||
<div style={META_LABEL}>Date</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{fmtDate}</div>
|
||||
</div>
|
||||
<div style={{ marginLeft: 'auto' }}>
|
||||
<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>
|
||||
@@ -713,8 +965,8 @@ export default function TaskDetail() {
|
||||
<button className="btn btn-outline" disabled={commentSaving || !commentBody.trim()} onClick={handlePostComment}>Post</button>
|
||||
</div>
|
||||
{comments.length === 0
|
||||
? <div style={{ color: 'var(--text-muted)', fontSize: 13 }}>No comments yet.</div>
|
||||
: comments.map((c, i) => {
|
||||
? <div className="card-empty-center">No comments</div>
|
||||
: commentRows.map((c, i) => {
|
||||
const d = new Date(c.created_at);
|
||||
const dateStr = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
const timeStr = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
|
||||
@@ -737,22 +989,6 @@ export default function TaskDetail() {
|
||||
}
|
||||
</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>
|
||||
@@ -776,7 +1012,7 @@ export default function TaskDetail() {
|
||||
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={LABEL}>Activity</div>
|
||||
{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 }}>
|
||||
{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' }} />
|
||||
</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>
|
||||
<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 style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 2 }}>Any file type accepted</div>
|
||||
</div>
|
||||
{reviewFiles.length > 0 && (
|
||||
<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>
|
||||
)}
|
||||
<FileAttachment files={reviewFiles} onChange={setReviewFiles} />
|
||||
</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 className="modal-action-row" style={MODAL_ACTION_ROW}>
|
||||
<button className="btn btn-outline" style={MODAL_ACTION_BUTTON} disabled={reviewSaving} onClick={handleConfirmReview}>{reviewSaving ? 'Submitting…' : 'Place in Review'}</button>
|
||||
<button className="btn btn-outline" style={MODAL_ACTION_BUTTON} disabled={reviewSaving} onClick={() => { setReviewModal(false); setReviewFiles([]); setReviewNotes(''); }}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -858,9 +1069,9 @@ export default function TaskDetail() {
|
||||
<div className="form-group">
|
||||
<FileAttachment files={revisionFiles} onChange={setRevisionFiles} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button className="btn btn-outline" 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}>
|
||||
<div className="modal-action-row" style={MODAL_ACTION_ROW}>
|
||||
<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={{ ...MODAL_ACTION_BUTTON, color: 'var(--accent)', borderColor: 'var(--accent)' }} disabled={revisionSaving || !revisionForm.description.trim()} onClick={handleSubmitRevision}>
|
||||
{revisionSaving ? 'Submitting…' : 'Submit Revision'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -868,6 +1079,30 @@ export default function TaskDetail() {
|
||||
</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 && (
|
||||
<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()}>
|
||||
@@ -878,35 +1113,11 @@ export default function TaskDetail() {
|
||||
</div>
|
||||
<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 = ''; }} />
|
||||
<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 style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 2 }}>Any file type accepted</div>
|
||||
</div>
|
||||
{amendFiles.length > 0 && (
|
||||
<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>
|
||||
)}
|
||||
<FileAttachment files={amendFiles} onChange={setAmendFiles} />
|
||||
</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 className="modal-action-row" style={MODAL_ACTION_ROW}>
|
||||
<button className="btn btn-outline" style={MODAL_ACTION_BUTTON} disabled={amendSaving} onClick={handleSubmitAmendment}>{amendSaving ? 'Saving…' : 'Save'}</button>
|
||||
<button className="btn btn-outline" style={MODAL_ACTION_BUTTON} disabled={amendSaving} onClick={() => { setAmendModal(false); setAmendForm({ description: '' }); setAmendFiles([]); }}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+238
-135
@@ -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 Layout from '../components/Layout';
|
||||
import PageLoader from '../components/PageLoader';
|
||||
import ProfileAvatar from '../components/ProfileAvatar';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import RequestForm from '../components/RequestForm';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { readPageCache, writePageCache } from '../lib/pageCache';
|
||||
import { withTimeout } from '../lib/withTimeout';
|
||||
import { getCurrentVersionForTask, getDeadlineSourceSubmission } from '../lib/taskDeadlines';
|
||||
import { getTaskDerivedState } from '../lib/taskVersions';
|
||||
import { logActivity } from '../lib/activityLog';
|
||||
import { fmtShortDate } from '../lib/dates';
|
||||
import { createInitialSubmissionForRequest, createTaskForRequest, findOrCreateProject } from '../lib/requestSubmission';
|
||||
import { ensureProjectFolder, ensureRequestFolder, uploadRequestFileToSurvey } from '../lib/folderSync';
|
||||
import { sendEmail } from '../lib/email';
|
||||
import { uploadFilesToRequestInfo, createTaskFolder } from '../lib/filebrowserFolders';
|
||||
import SortTh from '../components/SortTh';
|
||||
import { useSortable } from '../hooks/useSortable';
|
||||
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
|
||||
import { useRefetchOnFocus } from '../hooks/useRefetchOnFocus';
|
||||
import { useRealtimeSubscription } from '../hooks/useRealtimeSubscription';
|
||||
import { popupOverlayStyle } from '../lib/popupStyles';
|
||||
import { useLiveRefresh } from '../hooks/useLiveRefresh';
|
||||
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 = {
|
||||
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"/>',
|
||||
};
|
||||
|
||||
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 };
|
||||
|
||||
function TaskStatCard({ label, value, iconBg, iconColor, iconPath }) {
|
||||
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: '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={{ 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 }}>
|
||||
@@ -48,6 +53,7 @@ function TaskStatCard({ label, value, iconBg, iconColor, 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>
|
||||
);
|
||||
}
|
||||
@@ -58,14 +64,17 @@ function TasksStatsRow({ tasks = [] }) {
|
||||
const onHold = tasks.filter(t => t?.status === 'on_hold').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 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 (
|
||||
<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="In Progress" value={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="In Review" value={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="Total Tasks" value={tasks.length} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={TASK_STAT_ICONS.total} />
|
||||
<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} sub={pct(inProgress)} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconPath={TASK_STAT_ICONS.progress} />
|
||||
<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} sub={pct(inReview)} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={TASK_STAT_ICONS.review} />
|
||||
<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={total} sub={`${open} still open`} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={TASK_STAT_ICONS.total} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -80,7 +89,7 @@ function TasksPageShell({ statsTasks = [], projects = [], onAddProject = null, c
|
||||
};
|
||||
const completedStatuses = new Set(['completed', 'cancelled', 'archived', 'done']);
|
||||
const projectTabs = [
|
||||
{ id: 'all', label: 'All Projects' },
|
||||
{ id: 'all', label: 'All' },
|
||||
{ id: 'active', label: 'Active' },
|
||||
{ id: 'completed', label: 'Completed' },
|
||||
];
|
||||
@@ -89,6 +98,11 @@ function TasksPageShell({ statsTasks = [], projects = [], onAddProject = null, c
|
||||
const done = completedStatuses.has((p?.status || '').toLowerCase());
|
||||
return projTab === 'completed' ? done : !done;
|
||||
});
|
||||
const projectTabCounts = {
|
||||
all: projects.length,
|
||||
active: projects.filter((p) => !completedStatuses.has((p?.status || '').toLowerCase())).length,
|
||||
completed: projects.filter((p) => completedStatuses.has((p?.status || '').toLowerCase())).length,
|
||||
};
|
||||
const sortedProjects = [...visibleProjects].sort((a, b) => {
|
||||
if (projSortKey === 'status') {
|
||||
const ra = completedStatuses.has((a.status || '').toLowerCase()) ? 1 : 0;
|
||||
@@ -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={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10, flexShrink: 0 }}>
|
||||
{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 && (
|
||||
<div style={{ marginLeft: 'auto' }}>
|
||||
@@ -128,39 +149,41 @@ function TasksPageShell({ statsTasks = [], projects = [], onAddProject = null, c
|
||||
)}
|
||||
</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 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' }}>
|
||||
<colgroup>
|
||||
<col style={{ width: '60%' }} />
|
||||
<col style={{ width: '40%' }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="name" sortKey={projSortKey} sortDir={projSortDir} onSort={toggleProjSort} style={TASK_TABLE_TH_STYLE}>Name</SortTh>
|
||||
<SortTh col="status" sortKey={projSortKey} sortDir={projSortDir} onSort={toggleProjSort} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Status</SortTh>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedProjects.length === 0 ? (
|
||||
<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}>
|
||||
<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>
|
||||
</td>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-muted)', textAlign: 'center' }}>
|
||||
<Link to={`/projects/${p.id}`} className="table-link" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6, width: '100%' }}>
|
||||
<span style={{ width: 68, height: 4, borderRadius: 2, background: 'var(--border)', overflow: 'hidden', position: 'relative', flexShrink: 0 }}>
|
||||
<span style={{ position: 'absolute', inset: 0, width: `${projectCompletionPct(p)}%`, background: 'var(--accent)', borderRadius: 2 }} />
|
||||
</span>
|
||||
<span style={{ minWidth: 28, textAlign: 'right', fontSize: 12, color: 'var(--text-secondary)' }}>{projectCompletionPct(p)}%</span>
|
||||
</Link>
|
||||
</td>
|
||||
{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' }}>
|
||||
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||
<colgroup>
|
||||
<col style={{ width: '60%' }} />
|
||||
<col style={{ width: '40%' }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="name" sortKey={projSortKey} sortDir={projSortDir} onSort={toggleProjSort} style={TASK_TABLE_TH_STYLE}>Name</SortTh>
|
||||
<SortTh col="status" sortKey={projSortKey} sortDir={projSortDir} onSort={toggleProjSort} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Status</SortTh>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedProjects.map(p => (
|
||||
<tr key={p.id}>
|
||||
<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>
|
||||
</td>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-muted)', textAlign: 'center' }}>
|
||||
<Link to={`/projects/${p.id}`} className="table-link" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6, width: '100%' }}>
|
||||
<span style={{ width: 68, height: 4, borderRadius: 2, background: 'var(--border)', overflow: 'hidden', position: 'relative', flexShrink: 0 }}>
|
||||
<span style={{ position: 'absolute', inset: 0, width: `${projectCompletionPct(p)}%`, background: 'var(--accent)', borderRadius: 2 }} />
|
||||
</span>
|
||||
<span style={{ minWidth: 28, textAlign: 'right', fontSize: 12, color: 'var(--text-secondary)' }}>{projectCompletionPct(p)}%</span>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -174,10 +197,7 @@ export default function RequestsPage() {
|
||||
const isExternal = currentUser?.role === 'external';
|
||||
const isClient = currentUser?.role === 'client';
|
||||
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const refresh = useCallback(() => setRefreshKey(k => k + 1), []);
|
||||
useRefetchOnFocus(refresh);
|
||||
useRealtimeSubscription(['tasks', 'projects', 'submissions'], refresh);
|
||||
const { refreshKey } = useLiveRefresh(['tasks', 'projects', 'submissions']);
|
||||
|
||||
const teamCached = isTeam ? readPageCache('team_requests') : 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 [tasks, setTasks] = useState(() => teamCached?.tasks || extCached?.tasks || []);
|
||||
const [submissions, setSubmissions] = useState(() => teamCached?.submissions || extCached?.submissions || []);
|
||||
const [deliveries, setDeliveries] = useState([]);
|
||||
const [companies, setCompanies] = useState(() => teamCached?.companies || []);
|
||||
const [loading, setLoading] = useState(() => {
|
||||
if (isTeam) return !teamCached;
|
||||
@@ -193,7 +214,7 @@ export default function RequestsPage() {
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('not_started');
|
||||
const [activeTab, setActiveTab] = useState('new_requests');
|
||||
const [filterCompany, setFilterCompany] = useState('');
|
||||
const [filterProject, setFilterProject] = useState('');
|
||||
const { sortKey, sortDir, toggle, sort } = useSortable('status', 'asc');
|
||||
@@ -238,37 +259,11 @@ export default function RequestsPage() {
|
||||
try {
|
||||
setError(''); setLoadError(false);
|
||||
|
||||
let scopedProjectIds = null;
|
||||
let scopedTaskIds = null;
|
||||
|
||||
if (isClient) {
|
||||
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 { scopedProjectIds, scopedTaskIds } = await withTimeout(
|
||||
resolveScopedWorkIds(currentUser, { isClient, isExternal }),
|
||||
10000,
|
||||
'Tasks scope'
|
||||
);
|
||||
|
||||
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)')
|
||||
@@ -298,12 +293,38 @@ export default function RequestsPage() {
|
||||
);
|
||||
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) {
|
||||
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) {
|
||||
writePageCache(`ext-requests:${currentUser.id}`, { projects: p || [], tasks: t || [], submissions: subs || [] });
|
||||
writePageCache(`ext-requests:${currentUser.id}`, { projects: p || [], tasks: t || [], submissions: hydratedSubs });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Tasks page load failed:', err);
|
||||
@@ -317,7 +338,7 @@ export default function RequestsPage() {
|
||||
return () => { cancelled = true; };
|
||||
}, [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;
|
||||
setAddSaving(true); setAddError('');
|
||||
try {
|
||||
@@ -326,7 +347,6 @@ export default function RequestsPage() {
|
||||
const { task } = await createTaskForRequest({ projectId: resolvedProject.id, title: formData.title.trim(), requestKey: addRequestKey });
|
||||
if (!task) throw new Error('Failed to create task.');
|
||||
const taskCompany = companies?.find(c => c.id === formData.companyId);
|
||||
createTaskFolder(taskCompany?.name, resolvedProject.name, formData.title.trim()).catch(() => {});
|
||||
const { submission: sub } = await createInitialSubmissionForRequest({
|
||||
taskId: task.id, requestKey: addRequestKey, isHot: formData.isHot,
|
||||
serviceType: formData.serviceType, signFamily: formData.signFamily,
|
||||
@@ -335,12 +355,67 @@ export default function RequestsPage() {
|
||||
description: formData.description, submittedBy: formData.requestedBy, submittedByName: formData.requestedByName,
|
||||
});
|
||||
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(() => {});
|
||||
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([
|
||||
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 }),
|
||||
]);
|
||||
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());
|
||||
} catch (err) { setAddError(err.message); }
|
||||
finally { setAddSaving(false); }
|
||||
@@ -355,7 +430,6 @@ export default function RequestsPage() {
|
||||
const resolvedProject = await findOrCreateProject(formData.companyId, formData.project.trim(), existingProjects);
|
||||
const { task } = await createTaskForRequest({ projectId: resolvedProject.id, title: formData.title.trim(), requestKey: addRequestKey });
|
||||
if (!task) throw new Error('Failed to create task.');
|
||||
createTaskFolder(selectedCompany?.name, resolvedProject.name, formData.title.trim()).catch(() => {});
|
||||
const { submission } = await createInitialSubmissionForRequest({
|
||||
taskId: task.id, requestKey: addRequestKey, isHot: formData.isHot,
|
||||
serviceType: formData.serviceType, signFamily: formData.signFamily,
|
||||
@@ -363,28 +437,42 @@ export default function RequestsPage() {
|
||||
deadline: formData.deadline,
|
||||
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) {
|
||||
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}/${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);
|
||||
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: 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}`); }
|
||||
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 });
|
||||
}
|
||||
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: currentUser.name, clientEmail: currentUser.email,
|
||||
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,
|
||||
}).catch(() => {});
|
||||
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')
|
||||
.in('task_id', refreshTaskIds.length > 0 ? refreshTaskIds : ['__none__'])
|
||||
.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());
|
||||
} catch (err) { setAddError(err.message); }
|
||||
finally { setAddSaving(false); }
|
||||
@@ -417,6 +509,11 @@ export default function RequestsPage() {
|
||||
.from('projects').insert({ name, company_id: addProjectForm.companyId, status: 'active' })
|
||||
.select('id, name, status, company_id, company:companies(id, name)').single();
|
||||
if (insertError) throw insertError;
|
||||
try {
|
||||
await ensureProjectFolder({ projectId: newProject.id });
|
||||
} catch (folderError) {
|
||||
console.error('Project folder sync failed:', folderError);
|
||||
}
|
||||
setProjects(prev => [...prev, newProject]);
|
||||
setShowAddProjectForm(false); setAddProjectForm({ name: '', companyId: '' });
|
||||
} catch (err) { setAddProjectError(err.message || 'Failed to create project.'); }
|
||||
@@ -436,37 +533,29 @@ export default function RequestsPage() {
|
||||
const allRows = useMemo(() => {
|
||||
if (isClient) {
|
||||
return tasks.map(task => {
|
||||
const sub = submissions.find(s => s.task_id === task.id && s.type === 'initial');
|
||||
const derived = getTaskDerivedState(task, submissions, deliveries);
|
||||
return {
|
||||
id: task.id, title: task.title, status: task.status, projectId: task.project_id,
|
||||
serviceType: sub?.service_type || '—', deadline: sub?.deadline || null,
|
||||
version: task.current_version || 0, isHot: false,
|
||||
serviceType: derived.serviceType, deadline: derived.initialSubmission?.deadline || derived.deadline || null,
|
||||
version: derived.currentVersion, isHot: false,
|
||||
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 => {
|
||||
const taskSubs = submissions.filter(s => s.task_id === task.id);
|
||||
const deadlineSource = getDeadlineSourceSubmission(task, taskSubs);
|
||||
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 || '—';
|
||||
const derived = getTaskDerivedState(task, submissions, deliveries);
|
||||
if (derived.visibleTaskSubs.length === 0 || !derived.deadlineSource) return null;
|
||||
return {
|
||||
id: task.id, title: task.title, status: task.status, projectId: task.project_id,
|
||||
serviceType, deadline: deadlineSource.deadline,
|
||||
version: deadlineSource.version_number ?? 0,
|
||||
isHot: deadlineSource.is_hot || false,
|
||||
serviceType: derived.serviceType, deadline: derived.deadline,
|
||||
version: derived.currentVersion,
|
||||
isHot: derived.isHot,
|
||||
assignedName: task.assigned_name || null, assigneeAvatar: task.assignee?.avatar_url || null, assignedTo: task.assigned_to || null,
|
||||
submittedAt: latestGroup.length > 0
|
||||
? Math.max(...latestGroup.map(s => new Date(s.submitted_at).getTime()))
|
||||
: (task.submitted_at ? new Date(task.submitted_at).getTime() : 0),
|
||||
submittedAt: derived.latestActivityAt,
|
||||
};
|
||||
}).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(() => {
|
||||
return allRows
|
||||
@@ -482,16 +571,29 @@ export default function RequestsPage() {
|
||||
const doneStatuses = new Set(['client_approved', 'invoiced', 'paid']);
|
||||
|
||||
const tabs = [
|
||||
{ id: 'all', label: 'All Tasks' },
|
||||
{ id: 'not_started', label: 'To Do' },
|
||||
{ id: 'all', label: 'All' },
|
||||
{ id: 'new_requests', label: 'New Requests' },
|
||||
{ id: 'revisions', label: 'Revisions' },
|
||||
{ id: 'in_progress', label: 'In Progress' },
|
||||
{ id: 'on_hold', label: 'On Hold' },
|
||||
{ id: 'client_review', label: 'In Review' },
|
||||
{ id: 'completed', label: 'Completed' },
|
||||
];
|
||||
|
||||
const tabRows = activeTab === 'all' ? filteredRows
|
||||
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
|
||||
: 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);
|
||||
|
||||
const sortedRows = sort(tabRows, (row, key) => {
|
||||
@@ -517,7 +619,6 @@ export default function RequestsPage() {
|
||||
|
||||
const renderRow = (row) => {
|
||||
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 (
|
||||
<tr key={row.id}>
|
||||
<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 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) {
|
||||
const portrait = row.assigneeAvatar
|
||||
? <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>;
|
||||
const portrait = <ProfileAvatar name={row.assignedName} avatarUrl={row.assigneeAvatar} size={26} fontSize={10} />;
|
||||
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 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>;
|
||||
|
||||
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} />
|
||||
</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="button" className="btn btn-outline" style={TASK_MODAL_BUTTON_STYLE} onClick={() => { setShowAddProjectForm(false); setAddProjectForm({ name: '', companyId: '' }); setAddProjectError(''); }}>Cancel</button>
|
||||
</div>
|
||||
@@ -627,6 +725,11 @@ export default function RequestsPage() {
|
||||
{tabs.map(t => (
|
||||
<button key={t.id} onClick={() => setActiveTab(t.id)} className={`section-tab-btn${activeTab === t.id ? ' is-active' : ''}`}>
|
||||
{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>
|
||||
))}
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }} ref={companyFilterMenuRef}>
|
||||
@@ -692,9 +795,9 @@ export default function RequestsPage() {
|
||||
{/* 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' }}>
|
||||
{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 ? (
|
||||
<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' }}>
|
||||
<table className="table-sticky-head table-no-row-hover" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
|
||||
|
||||
@@ -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 { Link } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import LoadingButton from '../../components/LoadingButton';
|
||||
import SortTh from '../../components/SortTh';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { generateInvoicePDF } from '../../lib/invoice';
|
||||
import { generateInvoicePDF, generateReceiptPDF } from '../../lib/invoice';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { useSortable } from '../../hooks/useSortable';
|
||||
import { popupOverlayStyle, popupSurfaceStyle } from '../../lib/popupStyles';
|
||||
|
||||
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() {
|
||||
const { currentUser } = useAuth();
|
||||
const companies = (currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : [])).slice().sort((a, b) => a.name.localeCompare(b.name));
|
||||
const [activeCompanyId, setActiveCompanyId] = useState(companies[0]?.id || null);
|
||||
const companyIds = (currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : []))
|
||||
.map(company => company?.id)
|
||||
.filter(Boolean);
|
||||
|
||||
const [invoices, setInvoices] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [generatingInvoiceId, setGeneratingInvoiceId] = useState('');
|
||||
const { sortKey, sortDir, toggle, sort } = useSortable('invoice_date');
|
||||
const [activeInvoice, setActiveInvoice] = useState(null);
|
||||
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(() => {
|
||||
async function load() {
|
||||
if (companyIds.length === 0) {
|
||||
setInvoices([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const { data } = await supabase
|
||||
.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 });
|
||||
setInvoices((data || []).filter(inv => inv.status !== 'draft'));
|
||||
setLoading(false);
|
||||
}
|
||||
load();
|
||||
}, []);
|
||||
}, [currentUser?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleDownload = async (invoice) => {
|
||||
if (generatingInvoiceId) return;
|
||||
setGeneratingInvoiceId(invoice.id);
|
||||
try {
|
||||
await generateInvoicePDF(invoice, invoice.company, invoice.items || []);
|
||||
} finally {
|
||||
setGeneratingInvoiceId('');
|
||||
useEffect(() => {
|
||||
if (!filterMenuOpen) return;
|
||||
function onDocClick(e) {
|
||||
if (filterMenuRef.current && !filterMenuRef.current.contains(e.target)) setFilterMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const visible = companies.length > 1 && activeCompanyId
|
||||
? 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;
|
||||
document.addEventListener('mousedown', onDocClick);
|
||||
return () => document.removeEventListener('mousedown', onDocClick);
|
||||
}, [filterMenuOpen]);
|
||||
|
||||
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 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 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 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 = 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) };
|
||||
}), [visible, chartYear]);
|
||||
const hasChartData = chartData.some(d => d.Paid > 0 || d.Outstanding > 0);
|
||||
}), [filteredInvoices, chartYear]);
|
||||
|
||||
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 === 'total') return Number(inv.total || 0);
|
||||
if (key === 'company') return inv.company?.name || '';
|
||||
return inv[key] || '';
|
||||
});
|
||||
|
||||
const th = { sortKey, sortDir, onSort: toggle };
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">Invoices</div>
|
||||
<div className="page-subtitle">{visible.length} invoice{visible.length !== 1 ? 's' : ''}</div>
|
||||
<Layout loading={loading}>
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '3fr 2fr', gap: 24, flexShrink: 0 }}>
|
||||
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' }}>
|
||||
<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>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<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="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>
|
||||
<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 content={<ClientFinanceTooltip year={chartYear} />} />
|
||||
<Legend wrapperStyle={{ fontSize: 11, paddingTop: 8 }} />
|
||||
<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(#clientOutGrad)" dot={false} activeDot={{ r: 4 }} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, alignContent: 'start' }}>
|
||||
<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"/>'} />
|
||||
<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"/>'} />
|
||||
<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"/>'} />
|
||||
<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>
|
||||
</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="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
<table className="table-sticky-head table-no-row-hover" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||
<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>
|
||||
<tr>
|
||||
<SortTh col="invoice_number" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Invoice #</SortTh>
|
||||
<SortTh col="company" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Company</SortTh>
|
||||
<SortTh col="invoice_date" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Issued</SortTh>
|
||||
<SortTh col="due_date" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Due</SortTh>
|
||||
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Status</SortTh>
|
||||
<SortTh col="total" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Total</SortTh>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedInvoices.map(inv => {
|
||||
const isOverdue = inv.status !== 'paid' && inv.due_date && new Date(inv.due_date) < new Date();
|
||||
return (
|
||||
<tr key={inv.id}>
|
||||
<td style={{ padding: '5px 0' }}>
|
||||
<button type="button" className="table-link" onClick={() => setActiveInvoice(inv)}>{inv.invoice_number}</button>
|
||||
</td>
|
||||
<td style={{ padding: '5px 0', fontSize: 13 }}>
|
||||
{inv.company?.id ? (
|
||||
<Link to={`/company/${inv.company.id}`} className="table-link">
|
||||
{inv.company?.name || inv.bill_to || '—'}
|
||||
</Link>
|
||||
) : (
|
||||
inv.company?.name || inv.bill_to || '—'
|
||||
)}
|
||||
</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 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>
|
||||
<td style={{ padding: '5px 0' }}>
|
||||
<StatusBadge status={statusColor[inv.status] || 'not_started'} label={invoiceStatusLabel(inv.status)} />
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</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="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="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>
|
||||
</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="Paid" stroke="#4ade80" strokeWidth={2} fill="url(#gradPaidC)" dot={false} activeDot={{ r: 4 }} />
|
||||
<Area type="monotone" dataKey="Outstanding" stroke="#60a5fa" strokeWidth={2} fill="url(#gradOutC)" dot={false} activeDot={{ r: 4 }} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{companies.length > 1 && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<select className="filter-select" value={activeCompanyId || ''} onChange={e => setActiveCompanyId(e.target.value)}>
|
||||
{companies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</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 className="card">
|
||||
<div className="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="invoice_number" {...th}>Invoice #</SortTh>
|
||||
<SortTh col="invoice_date" {...th}>Issued</SortTh>
|
||||
<SortTh col="due_date" {...th}>Due</SortTh>
|
||||
<SortTh col="status" {...th}>Status</SortTh>
|
||||
<SortTh col="total" {...th}>Total</SortTh>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map(inv => {
|
||||
const isOverdue = inv.status !== 'paid' && new Date(inv.due_date) < new Date();
|
||||
return (
|
||||
<tr key={inv.id}>
|
||||
<td style={{ fontWeight: 400 }}>{inv.invoice_number}</td>
|
||||
<td style={{ color: 'var(--text-muted)' }}>{new Date(inv.invoice_date).toLocaleDateString()}</td>
|
||||
<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>
|
||||
<StatusBadge
|
||||
status={statusColor[inv.status] || 'not_started'}
|
||||
label={inv.status ? inv.status.charAt(0).toUpperCase() + inv.status.slice(1) : '—'}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ fontWeight: 400, color: 'var(--accent)' }}>${Number(inv.total).toFixed(2)}</td>
|
||||
<td>
|
||||
<LoadingButton
|
||||
className="btn btn-outline btn-sm"
|
||||
loading={generatingInvoiceId === inv.id}
|
||||
disabled={Boolean(generatingInvoiceId)}
|
||||
loadingText="Generating..."
|
||||
onClick={() => handleDownload(inv)}
|
||||
>
|
||||
Download PDF
|
||||
</LoadingButton>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ClientInvoiceModal invoice={activeInvoice} onClose={() => setActiveInvoice(null)} />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
+2
-288
@@ -1,296 +1,10 @@
|
||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import LoadingButton from '../../components/LoadingButton';
|
||||
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')}`;
|
||||
}
|
||||
import SubcontractorInvoiceForm from '../../components/SubcontractorInvoiceForm';
|
||||
|
||||
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 (
|
||||
<Layout>
|
||||
<button className="back-link" onClick={() => navigate('/my-invoices-sub')}>← 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>
|
||||
|
||||
{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>
|
||||
<SubcontractorInvoiceForm />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
+64
-186
@@ -1,34 +1,27 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import PageLoader from '../../components/PageLoader';
|
||||
import LoadingButton from '../../components/LoadingButton';
|
||||
import SortTh from '../../components/SortTh';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { generateSubcontractorPOPDF } from '../../lib/invoice';
|
||||
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' };
|
||||
|
||||
function fmt(val) {
|
||||
return `$${Number(val || 0).toFixed(2)}`;
|
||||
}
|
||||
|
||||
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() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { currentUser } = useAuth();
|
||||
|
||||
const [invoice, setInvoice] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const { sortKey, sortDir, toggle, sort } = useSortable('description');
|
||||
@@ -40,7 +33,11 @@ export default function MyInvoiceDetail() {
|
||||
.select('*, items:subcontractor_invoice_items(*)')
|
||||
.eq('id', id)
|
||||
.single();
|
||||
if (err || !data) { setError('Invoice not found.'); setLoading(false); return; }
|
||||
if (err || !data) {
|
||||
setError('Invoice not found.');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setInvoice(data);
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -49,24 +46,18 @@ export default function MyInvoiceDetail() {
|
||||
|
||||
async function handleSubmit() {
|
||||
setSubmitting(true);
|
||||
const submittedAt = new Date().toISOString();
|
||||
const { error: err } = await supabase
|
||||
.from('subcontractor_invoices')
|
||||
.update({ status: 'submitted', submitted_at: new Date().toISOString() })
|
||||
.update({ status: 'submitted', submitted_at: submittedAt })
|
||||
.eq('id', id);
|
||||
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);
|
||||
}
|
||||
|
||||
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() {
|
||||
if (!invoice) return;
|
||||
setDownloading(true);
|
||||
try {
|
||||
const total = invoiceTotal(invoice.items);
|
||||
@@ -92,13 +83,16 @@ export default function MyInvoiceDetail() {
|
||||
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>;
|
||||
|
||||
const total = invoiceTotal(invoice.items);
|
||||
const sortedItems = [...(invoice.items || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
|
||||
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 === 'quantity') return Number(item.quantity || 0);
|
||||
if (key === 'unit_price') return Number(item.unit_price || 0);
|
||||
@@ -106,175 +100,59 @@ export default function MyInvoiceDetail() {
|
||||
return '';
|
||||
});
|
||||
|
||||
const headerActions = (
|
||||
<>
|
||||
{invoice.status === 'draft' ? (
|
||||
<LoadingButton className="btn btn-outline" loading={submitting} loadingText="Submitting…" disabled={submitting} onClick={handleSubmit}>
|
||||
Submit to Team
|
||||
</LoadingButton>
|
||||
) : null}
|
||||
{invoice.status === 'paid' ? (
|
||||
<LoadingButton className="btn btn-outline" loading={downloading} loadingText="Generating…" disabled={downloading} onClick={handleDownload}>
|
||||
Download Receipt
|
||||
</LoadingButton>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<button className="back-link" onClick={() => navigate('/my-invoices-sub')}>← Back to Invoices</button>
|
||||
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">{invoice.invoice_number}</div>
|
||||
<div className="page-subtitle">Fourge Branding</div>
|
||||
</div>
|
||||
<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
|
||||
</LoadingButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="notification notification-info" style={{ marginBottom: 16 }}>{error}</div>}
|
||||
|
||||
<div className="grid-2" style={{ marginBottom: 24 }}>
|
||||
<div className="card">
|
||||
<div className="card-title">From</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>
|
||||
|
||||
<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">
|
||||
<label>Total</label>
|
||||
<p style={{ fontSize: 18, fontWeight: 400, color: 'var(--accent)' }}>{fmt(total)}</p>
|
||||
</div>
|
||||
{invoice.paid_at && (
|
||||
<div className="detail-item">
|
||||
<SubcontractorInvoiceDetailView
|
||||
error={error}
|
||||
headerActions={headerActions}
|
||||
leftCardTitle="From"
|
||||
leftCardBody={(
|
||||
<>
|
||||
<div style={{ fontSize: 15, fontWeight: 400 }}>{currentUser?.name || 'Subcontractor'}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 4 }}>{currentUser?.email || '—'}</div>
|
||||
</>
|
||||
)}
|
||||
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>Created</label><p>{new Date(invoice.created_at).toLocaleDateString()}</p></div>
|
||||
<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>
|
||||
<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' })}
|
||||
</p>
|
||||
</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 className="card">
|
||||
<div className="card-title">Actions</div>
|
||||
<div className="action-buttons">
|
||||
{invoice.status === 'draft' && (
|
||||
<LoadingButton
|
||||
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>
|
||||
)}
|
||||
sortKey={sortKey}
|
||||
sortDir={sortDir}
|
||||
onSort={toggle}
|
||||
items={tableItems}
|
||||
notes={invoice.notes}
|
||||
total={total}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
+524
-99
@@ -1,16 +1,27 @@
|
||||
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 StatusBadge from '../../components/StatusBadge';
|
||||
import SortTh from '../../components/SortTh';
|
||||
import LoadingButton from '../../components/LoadingButton';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
||||
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_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) {
|
||||
return `$${Number(val || 0).toFixed(2)}`;
|
||||
@@ -20,121 +31,535 @@ function invoiceTotal(items) {
|
||||
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() {
|
||||
const { currentUser } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const cacheKey = `ext-invoices:${currentUser?.id}`;
|
||||
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 [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(() => {
|
||||
if (!currentUser?.id) { setLoading(false); return; }
|
||||
supabase
|
||||
.from('subcontractor_invoices')
|
||||
.select('*, items:subcontractor_invoice_items(*)')
|
||||
.order('created_at', { ascending: false })
|
||||
.then(({ data, error: err }) => {
|
||||
if (err) setError(err.message);
|
||||
else { setInvoices(data || []); writePageCache(cacheKey, data || []); }
|
||||
async function load() {
|
||||
if (!currentUser?.id) {
|
||||
setInvoices([]);
|
||||
setStats({ completedNewTasks: 0, completedRevisions: 0, invoicedUnits: 0, paidUnits: 0 });
|
||||
setLoading(false);
|
||||
});
|
||||
}, [currentUser?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
return;
|
||||
}
|
||||
|
||||
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||
const chartYear = new Date().getFullYear();
|
||||
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 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);
|
||||
return { month, Submitted: +submittedAmt.toFixed(2), Paid: +paidAmt.toFixed(2) };
|
||||
}), [invoices, chartYear]);
|
||||
const hasChartData = chartData.some(d => d.Submitted > 0 || d.Paid > 0);
|
||||
const totalPaid = invoices.filter(i => i.status === 'paid').reduce((s, i) => s + invoiceTotal(i.items), 0);
|
||||
const totalPending = invoices.filter(i => i.status === 'submitted').reduce((s, i) => s + invoiceTotal(i.items), 0);
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const [{ data: invoiceRows, error: invoiceError }, { data: taskRows, error: taskError }] = await Promise.all([
|
||||
supabase
|
||||
.from('subcontractor_invoices')
|
||||
.select('*, items:subcontractor_invoice_items(*)')
|
||||
.order('created_at', { ascending: false }),
|
||||
supabase
|
||||
.from('tasks')
|
||||
.select('id, title, status, current_version, assigned_to')
|
||||
.in('status', ['not_started', 'in_progress', 'on_hold', 'client_review', 'client_approved', 'invoiced', 'paid'])
|
||||
.eq('assigned_to', currentUser.id)
|
||||
.order('title'),
|
||||
]);
|
||||
|
||||
if (invoiceError) throw invoiceError;
|
||||
if (taskError) throw taskError;
|
||||
|
||||
const tasks = taskRows || [];
|
||||
const invoicesData = invoiceRows || [];
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<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>
|
||||
<Layout loading={loading}>
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
{error && <div className="notification notification-info" style={{ marginBottom: 16 }}>{error}</div>}
|
||||
|
||||
<div className="dash-stat-grid" style={{ gridTemplateColumns: '1fr 1fr 1fr 1fr', flexShrink: 0 }}>
|
||||
<TaskStatCard
|
||||
label="Completed New Tasks"
|
||||
value={stats.completedNewTasks}
|
||||
sub={`${stats.completedNewTasks} completed R00 units`}
|
||||
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>
|
||||
<button className="btn btn-primary" onClick={() => navigate('/my-invoices-sub/new')} disabled={loading}>
|
||||
+ New Invoice
|
||||
</button>
|
||||
|
||||
{invoices.length === 0 ? (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10, flexShrink: 0, minHeight: 'var(--btn-height)' }}>
|
||||
<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 className="card card-empty-center" style={{ flex: 1, minHeight: 0 }}>No invoices</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10, flexShrink: 0, minHeight: 'var(--btn-height)' }}>
|
||||
<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>
|
||||
<tr>
|
||||
<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="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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedInvoices.map((inv) => {
|
||||
const total = invoiceTotal(inv.items);
|
||||
return (
|
||||
<tr key={inv.id} style={{ cursor: 'pointer' }} onClick={() => openInvoicePopup(inv.id)}>
|
||||
<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><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>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="notification notification-info" style={{ marginBottom: 16 }}>{error}</div>}
|
||||
|
||||
{!loading && invoices.length > 0 && (
|
||||
<>
|
||||
<div className="stat-bar" style={{ marginBottom: 18 }}>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
{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>
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="empty-state">Loading invoices...</div>
|
||||
) : invoices.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>No invoices yet</h3>
|
||||
<p>Create your first invoice to get paid for your completed work.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card">
|
||||
<div className="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="invoice_number" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Invoice #</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="total" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Total</SortTh>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sort(invoices, (inv, key) => {
|
||||
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);
|
||||
return (
|
||||
<tr key={inv.id} style={{ cursor: 'pointer' }} onClick={() => navigate(`/my-invoices-sub/${inv.id}`)}>
|
||||
<td style={{ fontWeight: 400 }}>{inv.invoice_number}</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 style={{ textAlign: 'right', fontWeight: 400, color: 'var(--accent)' }}>{fmt(total)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{(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>
|
||||
)}
|
||||
|
||||
+3
-5
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Layout from '../../components/Layout';
|
||||
import PageLoader from '../../components/PageLoader';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
@@ -95,14 +96,11 @@ export default function MyPurchaseOrders() {
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p style={{ color: 'var(--text-muted)' }}>Loading...</p>
|
||||
<PageLoader />
|
||||
) : error ? (
|
||||
<div className="card" style={{ color: 'var(--danger)' }}>{error}</div>
|
||||
) : purchaseOrders.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>No purchase orders</h3>
|
||||
<p>New POs will appear here when the Fourge team sends them.</p>
|
||||
</div>
|
||||
<div className="card card-empty-center">No purchase orders</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
{purchaseOrders.map(po => (
|
||||
|
||||
@@ -7,6 +7,8 @@ import { useAuth } from '../../context/AuthContext';
|
||||
import { generateInvoicePDF } from '../../lib/invoice';
|
||||
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
|
||||
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
|
||||
const INVOICE_TODAY = new Date().toISOString().split('T')[0];
|
||||
@@ -29,12 +31,6 @@ const buildRevisionItemDescription = (revision) => {
|
||||
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() {
|
||||
const navigate = useNavigate();
|
||||
const { currentUser } = useAuth();
|
||||
@@ -96,30 +92,30 @@ export default function CreateInvoice() {
|
||||
setInvoiceEmail(recipients[0]?.email || companies.find(c => c.id === selectedCompanyId)?.contact_email || '');
|
||||
if (projects && projects.length > 0) {
|
||||
const projectIds = projects.map(p => p.id);
|
||||
const { data: tasks } = await supabase
|
||||
const { data: companyTasks } = await supabase
|
||||
.from('tasks')
|
||||
.select('*, project:projects(name), submissions(service_type, type, version_number)')
|
||||
.in('project_id', projectIds)
|
||||
.eq('invoiced', false)
|
||||
.eq('status', 'client_approved');
|
||||
const approvedTaskIds = (tasks || []).map((t) => t.id).filter(Boolean);
|
||||
const { data: revisions } = approvedTaskIds.length > 0
|
||||
.in('status', ['not_started', 'in_progress', 'on_hold', 'client_review', 'client_approved', 'invoiced', 'paid']);
|
||||
const companyTaskIds = (companyTasks || []).map((t) => t.id).filter(Boolean);
|
||||
const { data: revisions } = companyTaskIds.length > 0
|
||||
? await supabase
|
||||
.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')
|
||||
.or('revision_type.eq.client_revision,revision_type.is.null')
|
||||
.eq('invoiced', false)
|
||||
.in('task_id', approvedTaskIds)
|
||||
.in('task_id', companyTaskIds)
|
||||
.order('submitted_at', { ascending: false })
|
||||
: { 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];
|
||||
return { ...t, service_type: initial?.service_type || t.title };
|
||||
});
|
||||
setUninvoicedTasks(tasksWithService);
|
||||
// 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();
|
||||
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}`;
|
||||
if (!revMap.has(key)) revMap.set(key, rev);
|
||||
}
|
||||
@@ -152,7 +148,7 @@ export default function CreateInvoice() {
|
||||
const serviceLabel = getRevisionServiceType(revision);
|
||||
const description = buildRevisionItemDescription(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 unitPrice = revisionChargeQty > 0 ? (price?.price || '') : 0;
|
||||
setItems(prev => {
|
||||
@@ -324,7 +320,7 @@ export default function CreateInvoice() {
|
||||
|
||||
return (
|
||||
<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>
|
||||
@@ -403,9 +399,10 @@ 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>
|
||||
<div style={{ fontSize: 13, fontWeight: 400 }}>{buildNewItemDescription(task)}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
{task.service_type || 'Other'} • {price ? `$${Number(price.price).toFixed(2)}` : 'No price set'}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
<span className="badge badge-initial">New</span>
|
||||
<span>{task.service_type || 'Other'} • {price ? `$${Number(price.price).toFixed(2)}` : 'No price set'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className={`btn btn-sm ${alreadyAdded ? 'btn-outline' : 'btn-primary'}`}
|
||||
@@ -441,13 +438,15 @@ export default function CreateInvoice() {
|
||||
{uninvoicedRevisions.map(rev => {
|
||||
const revServiceType = getRevisionServiceType(rev);
|
||||
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);
|
||||
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>
|
||||
<div style={{ fontSize: 13, fontWeight: 400 }}>{buildRevisionItemDescription(rev)}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
{revServiceType || 'Other'} • {price ? `$${Number(price.price).toFixed(2)}` : 'No price set'}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
<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>
|
||||
<button
|
||||
@@ -537,7 +536,7 @@ export default function CreateInvoice() {
|
||||
<LoadingButton className="btn btn-primary" onClick={() => handleSave('sent')} loading={saving} loadingText="Finalizing & Sending...">
|
||||
Finalise & Send
|
||||
</LoadingButton>
|
||||
<button className="btn btn-outline" onClick={() => navigate('/invoices')}>Cancel</button>
|
||||
<button className="btn btn-outline" onClick={() => navigate('/finances')}>Cancel</button>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
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_INPUT_STYLE = { minHeight: 42, margin: 0 };
|
||||
@@ -180,35 +179,12 @@ export default function CreateSubcontractorPO() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === 'sent') {
|
||||
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' } });
|
||||
navigate('/finances', { state: { tab: 'subcontractor-po' } });
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<button className="back-link" onClick={() => navigate('/invoices', { state: { tab: 'subcontractor-po' } })}>← Back to Subcontractor PO</button>
|
||||
<Layout loading={loading}>
|
||||
<button className="back-link" onClick={() => navigate('/finances', { state: { tab: 'subcontractor-po' } })}>← Back to Subcontractor PO</button>
|
||||
|
||||
<div className="page-header">
|
||||
<div>
|
||||
|
||||
+228
-249
@@ -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 Layout from '../../components/Layout';
|
||||
import PageLoader from '../../components/PageLoader';
|
||||
import SortTh from '../../components/SortTh';
|
||||
import ProfileAvatar from '../../components/ProfileAvatar';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
||||
import { withTimeout } from '../../lib/withTimeout';
|
||||
import { getDeadlineSourceSubmission } from '../../lib/taskDeadlines';
|
||||
import { useSortable } from '../../hooks/useSortable';
|
||||
import { useRefetchOnFocus } from '../../hooks/useRefetchOnFocus';
|
||||
import { useRealtimeSubscription } from '../../hooks/useRealtimeSubscription';
|
||||
import { useLiveRefresh } from '../../hooks/useLiveRefresh';
|
||||
import { resolveScopedWorkIds } from '../../lib/workScope';
|
||||
import { getSubmissionDisplayName, mergeSubmissionDisplayNames } from '../../lib/submissionDisplay';
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -32,22 +35,6 @@ function fmtMoney(n) {
|
||||
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 = {
|
||||
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"/> },
|
||||
@@ -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) {
|
||||
if (pts.length < 2) return '';
|
||||
let d = `M${pts[0][0].toFixed(1)},${pts[0][1].toFixed(1)}`;
|
||||
@@ -373,170 +341,159 @@ 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 { sortKey, sortDir, toggle, sort } = useSortable('deadline');
|
||||
const taskMap = new Map((tasks || []).map(t => [t.id, t]));
|
||||
const seen = new Set();
|
||||
const hotItems = (submissions || [])
|
||||
.filter(s => {
|
||||
const task = taskMap.get(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);
|
||||
})
|
||||
.map(s => ({ ...s, task: taskMap.get(s.task_id) }))
|
||||
.filter(s => s.task)
|
||||
.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, 7);
|
||||
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 => {
|
||||
const task = taskMap.get(s.task_id);
|
||||
if (isClient) return task?.status === 'client_review' && !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) }))
|
||||
.filter(s => s.task)
|
||||
.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, 7);
|
||||
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 === 'requested_by') return s.submitted_by_name || '';
|
||||
if (key === 'requested_by') return getSubmissionDisplayName(s) || '';
|
||||
if (key === 'deadline') return s.deadline || '';
|
||||
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 }}>
|
||||
<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>
|
||||
</div>
|
||||
{hotItems.length === 0 ? (
|
||||
<div style={{ minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>
|
||||
{isClient ? 'No tasks ready for review' : 'No hot tasks'}
|
||||
<div style={{ flex: 1, minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>
|
||||
{isClient ? 'No tasks ready for review' : isExternal ? 'No active tasks assigned to you' : 'No hot tasks'}
|
||||
</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||
<colgroup>
|
||||
<col style={{ width: '10%' }} />
|
||||
<col style={{ width: '40%' }} />
|
||||
<col style={{ width: '35%' }} />
|
||||
<col style={{ width: '15%' }} />
|
||||
</colgroup>
|
||||
<thead style={{ background: 'transparent' }}>
|
||||
<tr style={{ background: 'transparent' }}>
|
||||
<th style={{ padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top', textAlign: 'center' }} />
|
||||
<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="requested_by" 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' }}>Requested By</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(s => (
|
||||
<tr key={s.task_id} style={{ verticalAlign: 'middle', background: 'transparent' }}>
|
||||
<td style={{ padding: '3px 5px 7px', border: 'none', background: 'transparent', textAlign: 'center', verticalAlign: 'middle' }}>
|
||||
{s.task.status === 'client_approved' ? (
|
||||
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="rgba(74,222,128,0.8)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'block', margin: '0 auto' }}>
|
||||
<rect x="1.5" y="1.5" width="13" height="13" rx="2"/><polyline points="4,8 6.5,10.5 12,5.5"/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="var(--text-muted)" strokeWidth="1.5" strokeLinecap="round" style={{ display: 'block', margin: '0 auto' }}>
|
||||
<rect x="1.5" y="1.5" width="13" height="13" rx="2"/>
|
||||
</svg>
|
||||
)}
|
||||
</td>
|
||||
<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/' + s.task_id)}>{s.task.title}</button>
|
||||
</td>
|
||||
<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 ? (
|
||||
<button type="button" className="dashboard-inline-link" onClick={() => navigate(profilePath(s.submitted_by, s.submitter_role))}>{s.submitted_by_name || 'Profile'}</button>
|
||||
) : (s.submitted_by_name || '—')}
|
||||
</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>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
<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>
|
||||
<col style={{ width: '10%' }} />
|
||||
<col style={{ width: '40%' }} />
|
||||
<col style={{ width: '35%' }} />
|
||||
<col style={{ width: '15%' }} />
|
||||
</colgroup>
|
||||
<thead style={{ background: 'transparent' }}>
|
||||
<tr style={{ background: 'transparent' }}>
|
||||
<th style={{ padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top', textAlign: 'center' }} />
|
||||
<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="requested_by" 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' }}>Requested By</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(s => (
|
||||
<tr key={s.task_id} style={{ verticalAlign: 'middle', background: 'transparent' }}>
|
||||
<td style={{ padding: '3px 5px 7px', border: 'none', background: 'transparent', textAlign: 'center', verticalAlign: 'middle' }}>
|
||||
{s.task.status === 'client_approved' ? (
|
||||
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="rgba(74,222,128,0.8)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'block', margin: '0 auto' }}>
|
||||
<rect x="1.5" y="1.5" width="13" height="13" rx="2"/><polyline points="4,8 6.5,10.5 12,5.5"/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="var(--text-muted)" strokeWidth="1.5" strokeLinecap="round" style={{ display: 'block', margin: '0 auto' }}>
|
||||
<rect x="1.5" y="1.5" width="13" height="13" rx="2"/>
|
||||
</svg>
|
||||
)}
|
||||
</td>
|
||||
<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/' + s.task_id)}>{s.task.title}</button>
|
||||
</td>
|
||||
<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 ? (
|
||||
<button type="button" className="dashboard-inline-link" onClick={() => navigate(profilePath(s.submitted_by, s.submitter_role))}>{getSubmissionDisplayName(s) || 'Profile'}</button>
|
||||
) : (getSubmissionDisplayName(s) || '—')}
|
||||
</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>
|
||||
))}
|
||||
</tbody>
|
||||
</>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientHighlightCard({ highlights }) {
|
||||
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 }) {
|
||||
function TeamPerformanceCard({ tasks, profiles, deliveries, cardHeight = null }) {
|
||||
const navigate = useNavigate();
|
||||
const profileMap = useMemo(() => {
|
||||
const m = new Map();
|
||||
(profiles || []).forEach(p => m.set(p.id, p));
|
||||
return m;
|
||||
}, [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 toEST = (dateStr) => new Date(new Date(dateStr).toLocaleString('en-US', { timeZone: 'America/New_York' }));
|
||||
const monthsWithData = useMemo(() => new Set(
|
||||
@@ -556,9 +513,12 @@ function TeamPerformanceCard({ tasks, profiles, deliveries }) {
|
||||
}, []);
|
||||
const monthOpts = allMonthOpts.filter(o => monthsWithData.has(o.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 [year, month] = monthKey.split('-').map(Number);
|
||||
const [year, month] = effectiveMonthKey.split('-').map(Number);
|
||||
const map = new Map();
|
||||
(deliveries || []).forEach(d => {
|
||||
const task = d.submission?.task;
|
||||
@@ -567,35 +527,50 @@ function TeamPerformanceCard({ tasks, profiles, deliveries }) {
|
||||
if (dt.getFullYear() !== year || dt.getMonth() + 1 !== month) return;
|
||||
const name = d.sent_by || task.assigned_name;
|
||||
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;
|
||||
else entry.revCount += 1;
|
||||
map.set(name, entry);
|
||||
});
|
||||
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) };
|
||||
}, [deliveries, monthKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [deliveries, effectiveMonthKey, profileMap, profileNameMap]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
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 }}>
|
||||
<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' }}>
|
||||
<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>)}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
{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) => {
|
||||
const pct = totalDone > 0 ? Math.round((p.done / totalDone) * 100) : 0;
|
||||
return (
|
||||
<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={{ 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>}
|
||||
@@ -628,26 +603,40 @@ export default function TeamDashboard() {
|
||||
const isClient = currentUser?.role === 'client';
|
||||
const isExternal = currentUser?.role === 'external';
|
||||
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const refresh = useCallback(() => setRefreshKey(k => k + 1), []);
|
||||
useRefetchOnFocus(refresh);
|
||||
useRealtimeSubscription(['tasks', 'projects', 'submissions', 'activity_log', 'profiles'], refresh);
|
||||
const { refreshKey } = useLiveRefresh(['tasks', 'projects', 'submissions', 'activity_log', 'profiles']);
|
||||
|
||||
const cached = isTeam ? readPageCache(CACHE_KEY, 5 * 60_000) : null;
|
||||
|
||||
const [tasks, setTasks] = useState(() => cached?.tasks || []);
|
||||
const [projects, setProjects] = useState(() => cached?.projects || []);
|
||||
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 [allProfiles, setAllProfiles] = useState(() => cached?.allProfiles || []);
|
||||
const [teamInvoices, setTeamInvoices] = useState(() => cached?.teamInvoices || []);
|
||||
const [teamExpenses, setTeamExpenses] = useState([]);
|
||||
const [teamSubcontractorPOs, setTeamSubcontractorPOs] = useState([]);
|
||||
const [teamSubInvoices, setTeamSubInvoices] = useState([]);
|
||||
const [myInvoices, setMyInvoices] = useState([]);
|
||||
const [perfDeliveries, setPerfDeliveries] = useState([]);
|
||||
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(() => {
|
||||
let cancelled = false;
|
||||
@@ -658,37 +647,16 @@ export default function TeamDashboard() {
|
||||
cutoff.setMonth(cutoff.getMonth() - CUTOFF_MONTHS);
|
||||
const cutoffStr = cutoff.toISOString();
|
||||
|
||||
let scopedTaskIds = null;
|
||||
let scopedProjectIds = null;
|
||||
let scopedCompanyIds = null;
|
||||
|
||||
if (!isTeam && isClient) {
|
||||
const companyIds = [...new Set([...(currentUser?.companies?.map(c => c.company?.id || c.id) || []), ...(currentUser?.company_id ? [currentUser.company_id] : [])])].filter(Boolean);
|
||||
scopedCompanyIds = companyIds;
|
||||
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 {
|
||||
scopedTaskIds,
|
||||
scopedProjectIds,
|
||||
scopedCompanyIds,
|
||||
} = isTeam
|
||||
? { scopedTaskIds: null, scopedProjectIds: null, scopedCompanyIds: null }
|
||||
: await resolveScopedWorkIds(currentUser, { isClient, isExternal });
|
||||
|
||||
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)
|
||||
.order('submitted_at', { ascending: false });
|
||||
if (isClient) {
|
||||
@@ -724,10 +692,10 @@ export default function TeamDashboard() {
|
||||
}
|
||||
|
||||
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
|
||||
? ((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: [] }))
|
||||
: 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')
|
||||
: 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,
|
||||
projectsQuery,
|
||||
submissionsQuery,
|
||||
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('companies').select('id, name').order('name'),
|
||||
supabase.from('company_members').select('company_id, profile_id'),
|
||||
invoicesPromise,
|
||||
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');
|
||||
if (cancelled) return;
|
||||
|
||||
@@ -754,14 +730,14 @@ export default function TeamDashboard() {
|
||||
const roleById = new Map((profiles || []).map(pr => [pr.id, 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 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);
|
||||
setProjects(p || []);
|
||||
setSubmissions(subsWithRole);
|
||||
setClientProfiles((profiles || []).filter(pr => pr.role === 'client'));
|
||||
setAllProfiles(profiles || []);
|
||||
setAllCompanies(cos || []);
|
||||
setCompanyMemberships(memRows || []);
|
||||
const scopedActivity = isTeam
|
||||
? (activity || [])
|
||||
: (activity || []).filter((entry) => {
|
||||
@@ -773,16 +749,19 @@ export default function TeamDashboard() {
|
||||
setTeamInvoices(isTeam ? (invs || []) : []);
|
||||
setMyInvoices(!isTeam ? (invs || []) : []);
|
||||
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) {
|
||||
writePageCache(CACHE_KEY, {
|
||||
tasks: tasksWithDeadlines,
|
||||
projects: p || [],
|
||||
submissions: subsWithRole,
|
||||
clientProfiles: (profiles || []).filter(pr => pr.role === 'client'),
|
||||
companies: cos || [],
|
||||
companyMemberships: memRows || [],
|
||||
activityLog: activity || [],
|
||||
teamInvoices: invs || [],
|
||||
allProfiles: profiles || [],
|
||||
@@ -797,12 +776,6 @@ export default function TeamDashboard() {
|
||||
return () => { cancelled = true; };
|
||||
}, [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(() =>
|
||||
(activityLog || []).map(e => ({
|
||||
time: new Date(e.created_at),
|
||||
@@ -817,7 +790,7 @@ export default function TeamDashboard() {
|
||||
})).filter(e => !isNaN(e.time)).slice(0, 10),
|
||||
[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 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 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 now = new Date();
|
||||
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 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
|
||||
? { 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 };
|
||||
@@ -875,15 +859,10 @@ export default function TeamDashboard() {
|
||||
<TasksInProgressCard tasks={inProgressTasks} />
|
||||
<MiniCalendar items={calendarItems} />
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginTop: 24 }}>
|
||||
<HotItemsCard submissions={submissions} tasks={tasks} isClient={isClient} />
|
||||
<TeamPerformanceCard tasks={tasks} profiles={allProfiles} deliveries={perfDeliveries} />
|
||||
<div ref={bottomCardsRowRef} style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginTop: 24 }}>
|
||||
<HotItemsCard submissions={submissions} tasks={tasks} isClient={isClient} isExternal={isExternal} currentUserId={currentUser?.id || null} cardHeight={bottomCardsHeight} />
|
||||
<TeamPerformanceCard tasks={tasks} profiles={allProfiles} deliveries={perfDeliveries} cardHeight={bottomCardsHeight} />
|
||||
</div>
|
||||
{isTeam && (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<ClientHighlightCard highlights={teamHighlights} />
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import Layout from '../../components/Layout';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { formatShortDateTime } from '../../lib/dates';
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
@@ -17,17 +18,6 @@ function sortEntries(entries) {
|
||||
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) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) return '';
|
||||
@@ -253,7 +243,7 @@ export default function FourgePasswords() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Layout loading={loading}>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">Fourge Passwords</div>
|
||||
@@ -294,10 +284,7 @@ export default function FourgePasswords() {
|
||||
{loading ? (
|
||||
<div style={{ color: 'var(--text-muted)' }}>Loading passwords...</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="empty-state" style={{ padding: '28px 20px' }}>
|
||||
<h3 style={{ marginBottom: 8 }}>No passwords yet</h3>
|
||||
<p>Add encrypted password entries for internal services and team access.</p>
|
||||
</div>
|
||||
<div className="card-empty-center" style={{ minHeight: 120 }}>No passwords</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
{entries.map(entry => (
|
||||
@@ -316,7 +303,7 @@ export default function FourgePasswords() {
|
||||
<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 }}>
|
||||
Updated {formatDate(entry.updated_at || entry.created_at)}
|
||||
Updated {formatShortDateTime(entry.updated_at || entry.created_at, 'Unknown')}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate, useLocation, Link } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import PageLoader from '../../components/PageLoader';
|
||||
import LoadingButton from '../../components/LoadingButton';
|
||||
import SortTh from '../../components/SortTh';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
@@ -9,15 +10,20 @@ import { generateInvoicePDF, generateReceiptPDF } from '../../lib/invoice';
|
||||
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
|
||||
import { withTimeout } from '../../lib/withTimeout';
|
||||
import { useSortable } from '../../hooks/useSortable';
|
||||
import { popupOverlayStyle, popupSurfaceStyle } from '../../lib/popupStyles';
|
||||
|
||||
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
|
||||
|
||||
export default function InvoiceDetail() {
|
||||
const { id } = useParams();
|
||||
export function TeamInvoiceDetailPanel({
|
||||
invoiceId,
|
||||
initialInvoice = null,
|
||||
embedded = false,
|
||||
onClose,
|
||||
onInvoiceUpdated,
|
||||
onInvoiceDeleted,
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const { state } = useLocation();
|
||||
|
||||
const [invoice, setInvoice] = useState(state?.invoice || null);
|
||||
const [invoice, setInvoice] = useState(initialInvoice || null);
|
||||
const [company, setCompany] = useState(null);
|
||||
const [companies, setCompanies] = useState([]);
|
||||
const [items, setItems] = useState([]);
|
||||
@@ -32,14 +38,14 @@ export default function InvoiceDetail() {
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
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;
|
||||
setInvoice(inv);
|
||||
|
||||
const [{ data: co }, { data: companyList }, { data: its }] = await Promise.all([
|
||||
supabase.from('companies').select('*').eq('id', inv.company_id).single(),
|
||||
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);
|
||||
setCompany(co);
|
||||
@@ -53,23 +59,47 @@ export default function InvoiceDetail() {
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, [id]);
|
||||
}, [invoiceId]);
|
||||
|
||||
const syncInvoiceState = (updater) => {
|
||||
setInvoice((current) => {
|
||||
const next = typeof updater === 'function' ? updater(current) : updater;
|
||||
onInvoiceUpdated?.(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const updateStatus = async (status) => {
|
||||
setSaving(true);
|
||||
const updates = { status };
|
||||
if (status === 'paid' && !invoice.paid_at) updates.paid_at = new Date().toISOString();
|
||||
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) {
|
||||
setInvoice(i => ({ ...i, ...updates }));
|
||||
syncInvoiceState(i => ({ ...i, ...updates }));
|
||||
// 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);
|
||||
if (taskIds.length > 0) {
|
||||
const newTaskStatus = status === 'paid' ? 'paid' : status === 'sent' ? 'invoiced' : 'client_approved';
|
||||
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 {
|
||||
alert('Failed to update status.');
|
||||
}
|
||||
@@ -104,12 +134,12 @@ export default function InvoiceDetail() {
|
||||
const contactEmail = getEmailRecipient();
|
||||
if (!contactEmail) throw new Error('Enter an email recipient before sending.');
|
||||
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,
|
||||
'Saving invoice email'
|
||||
);
|
||||
if (error) throw error;
|
||||
setInvoice(inv => ({ ...inv, invoice_email: contactEmail }));
|
||||
syncInvoiceState(inv => ({ ...inv, invoice_email: contactEmail }));
|
||||
return contactEmail;
|
||||
};
|
||||
|
||||
@@ -167,12 +197,12 @@ export default function InvoiceDetail() {
|
||||
|
||||
const updates = { status: 'sent' };
|
||||
const { error } = await withTimeout(
|
||||
supabase.from('invoices').update(updates).eq('id', id),
|
||||
supabase.from('invoices').update(updates).eq('id', invoiceId),
|
||||
12000,
|
||||
'Updating invoice status'
|
||||
);
|
||||
if (error) throw error;
|
||||
setInvoice(i => ({ ...i, ...updates }));
|
||||
syncInvoiceState(i => ({ ...i, ...updates }));
|
||||
alert(`Invoice email sent successfully.${attachmentWarning || ''}`);
|
||||
} catch (err) {
|
||||
console.error('Failed to finalize and send invoice:', err);
|
||||
@@ -202,16 +232,22 @@ export default function InvoiceDetail() {
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (invoice?.status === 'paid') {
|
||||
alert('Paid invoices cannot be deleted.');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
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);
|
||||
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);
|
||||
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;
|
||||
navigate('/invoices');
|
||||
onInvoiceDeleted?.(invoiceId);
|
||||
if (embedded) onClose?.();
|
||||
else navigate('/finances');
|
||||
} catch {
|
||||
alert('Failed to delete invoice. Please try again.');
|
||||
setSaving(false);
|
||||
@@ -279,8 +315,8 @@ export default function InvoiceDetail() {
|
||||
await supabase.from('invoices').update({
|
||||
invoice_date: dateForm.invoice_date,
|
||||
due_date: dateForm.due_date,
|
||||
}).eq('id', id);
|
||||
setInvoice(i => ({ ...i, invoice_date: dateForm.invoice_date, due_date: dateForm.due_date }));
|
||||
}).eq('id', invoiceId);
|
||||
syncInvoiceState(i => ({ ...i, invoice_date: dateForm.invoice_date, due_date: dateForm.due_date }));
|
||||
setEditingDates(false);
|
||||
setSaving(false);
|
||||
};
|
||||
@@ -288,8 +324,8 @@ export default function InvoiceDetail() {
|
||||
const handleEmailBlur = async () => {
|
||||
const nextEmail = getEmailRecipient();
|
||||
if ((invoice.invoice_email || '') === nextEmail) return;
|
||||
const { error } = await supabase.from('invoices').update({ invoice_email: nextEmail || null }).eq('id', id);
|
||||
if (!error) setInvoice(inv => ({ ...inv, invoice_email: nextEmail || null }));
|
||||
const { error } = await supabase.from('invoices').update({ invoice_email: nextEmail || null }).eq('id', invoiceId);
|
||||
if (!error) syncInvoiceState(inv => ({ ...inv, invoice_email: nextEmail || null }));
|
||||
};
|
||||
|
||||
const handleCompanyChange = async (companyId) => {
|
||||
@@ -302,19 +338,37 @@ export default function InvoiceDetail() {
|
||||
company_id: companyId,
|
||||
bill_to: nextCompany.name,
|
||||
invoice_email: defaultEmail,
|
||||
}).eq('id', id);
|
||||
}).eq('id', invoiceId);
|
||||
setSaving(false);
|
||||
if (error) {
|
||||
alert('Failed to update invoice company. Please try again.');
|
||||
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);
|
||||
setEmailRecipient(defaultEmail);
|
||||
};
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
if (!invoice) return <Layout><p>Invoice not found.</p></Layout>;
|
||||
if (loading) {
|
||||
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) => {
|
||||
if (key === 'type') return item.submission_id ? 'Revision' : 'New';
|
||||
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 detailContent = (
|
||||
<>
|
||||
{!embedded && <button className="back-link" onClick={() => navigate('/finances')}>← Back to Invoices</button>}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<button className="back-link" onClick={() => navigate('/invoices')}>← Back to Invoices</button>
|
||||
|
||||
<div className="page-header">
|
||||
<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}>
|
||||
<div>
|
||||
<div className="page-title">{invoice.invoice_number}</div>
|
||||
<div className="page-subtitle">
|
||||
<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={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>
|
||||
</div>
|
||||
</div>
|
||||
<div className="action-buttons">
|
||||
<div className="action-buttons" style={embedded ? { justifyContent: 'flex-end' } : undefined}>
|
||||
<StatusBadge
|
||||
status={statusColor[invoice.status] || 'not_started'}
|
||||
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 className="grid-2" style={{ marginBottom: 24 }}>
|
||||
<div className="grid-2" style={{ marginTop: embedded ? 18 : 0, marginBottom: 24 }}>
|
||||
<div className="card">
|
||||
<div className="card-title">Bill To</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 400 }}>{invoice.bill_to || company?.name}</div>
|
||||
@@ -510,9 +563,33 @@ export default function InvoiceDetail() {
|
||||
</LoadingButton>
|
||||
</>
|
||||
)}
|
||||
<button className="btn-icon btn-icon-danger" title="Delete Invoice" onClick={handleDelete} disabled={saving}>✕</button>
|
||||
{invoice.status !== 'paid' && (
|
||||
<button className="btn-icon btn-icon-danger" title="Delete Invoice" onClick={handleDelete} disabled={saving}>✕</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return <Layout>{detailContent}</Layout>;
|
||||
}
|
||||
|
||||
export default function InvoiceDetail() {
|
||||
const { id } = useParams();
|
||||
const { state } = useLocation();
|
||||
|
||||
return <TeamInvoiceDetailPanel invoiceId={id} initialInvoice={state?.invoice || null} />;
|
||||
}
|
||||
|
||||
+462
-85
@@ -7,6 +7,7 @@ import SortTh from '../../components/SortTh';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import { useSortable } from '../../hooks/useSortable';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
||||
import { exportCPAPackage, generateSubcontractorPOPDF, generateInvoicePDF } from '../../lib/invoice';
|
||||
import { withTimeout } from '../../lib/withTimeout';
|
||||
@@ -14,6 +15,9 @@ import LoadingButton from '../../components/LoadingButton';
|
||||
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
|
||||
import { popupOverlayStyle, popupSurfaceStyle } from '../../lib/popupStyles';
|
||||
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 statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
|
||||
@@ -40,12 +44,156 @@ const invoiceStatusBadgeLabel = (status) => {
|
||||
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_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_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) =>
|
||||
({ 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 = () => ({
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
description: '',
|
||||
@@ -78,6 +226,7 @@ function FinancesChartTooltip({ active, payload, label, year }) {
|
||||
}
|
||||
|
||||
export default function Invoices() {
|
||||
const { currentUser } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const cached = readPageCache('team_invoices');
|
||||
@@ -126,6 +275,9 @@ export default function Invoices() {
|
||||
const [subInvoicesLoading, setSubInvoicesLoading] = useState(true);
|
||||
const [subInvoicesError, setSubInvoicesError] = useState('');
|
||||
const [markingPaid, setMarkingPaid] = useState('');
|
||||
const [viewingSubInvoice, setViewingSubInvoice] = useState(null);
|
||||
const [viewingSubInvoiceTaskTypeMap, setViewingSubInvoiceTaskTypeMap] = useState({});
|
||||
const [viewingInvoice, setViewingInvoice] = useState(null);
|
||||
const [expandedSubInvoiceId, setExpandedSubInvoiceId] = useState(null);
|
||||
|
||||
// ── New Invoice form ──────────────────────────────────────────────────────
|
||||
@@ -143,11 +295,30 @@ export default function Invoices() {
|
||||
const [invSaving, setInvSaving] = useState(false);
|
||||
const [invLoadingTasks, setInvLoadingTasks] = useState(false);
|
||||
const invDragItem = useRef(null);
|
||||
const pageLoading = loading || expensesLoading || subcontractorLoading || subInvoicesLoading;
|
||||
|
||||
useEffect(() => {
|
||||
supabase.from('companies').select('id, name, contact_email').order('name').then(({ data }) => setInvCompanies(data || []));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function loadSubInvoiceTaskTypes() {
|
||||
if (!viewingSubInvoice?.items?.length) {
|
||||
setViewingSubInvoiceTaskTypeMap({});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const map = await fetchSubInvoiceTaskTypeMap(asArray(viewingSubInvoice.items).map((item) => item.task_id));
|
||||
if (!cancelled) setViewingSubInvoiceTaskTypeMap(map);
|
||||
} catch {
|
||||
if (!cancelled) setViewingSubInvoiceTaskTypeMap({});
|
||||
}
|
||||
}
|
||||
loadSubInvoiceTaskTypes();
|
||||
return () => { cancelled = true; };
|
||||
}, [viewingSubInvoice]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!invCompanyId) { setInvUnbilledTasks([]); setInvUnbilledRevisions([]); setInvPriceList([]); setInvItems([invNewItem()]); setInvBillTo(''); setInvEmail(''); setInvRecipients([]); return; }
|
||||
const co = invCompanies.find(c => c.id === invCompanyId);
|
||||
@@ -163,12 +334,32 @@ export default function Invoices() {
|
||||
setInvEmail((users || [])[0]?.email || co?.contact_email || '');
|
||||
if (projects?.length > 0) {
|
||||
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 tids = (tasks || []).map(t => t.id);
|
||||
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: [] };
|
||||
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 }; }));
|
||||
const { data: companyTasks } = await supabase
|
||||
.from('tasks')
|
||||
.select('*, project:projects(name), submissions(service_type, type, version_number)')
|
||||
.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();
|
||||
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()]);
|
||||
} else { setInvUnbilledTasks([]); setInvUnbilledRevisions([]); }
|
||||
setInvLoadingTasks(false);
|
||||
@@ -177,18 +368,17 @@ export default function Invoices() {
|
||||
|
||||
const invAddTask = (task) => {
|
||||
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)]);
|
||||
};
|
||||
const invAddRevision = (rev) => {
|
||||
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 price = invPriceList.find(p => p.service_type === svcType && p.price_type === 'revision');
|
||||
const version = Number(rev.version_number || 0);
|
||||
const qty = version >= 2 ? 1 : 1;
|
||||
const unitPrice = version >= 2 ? (price?.price || '') : 0;
|
||||
const vLabel = 'R' + String(version).padStart(2, '0');
|
||||
const desc = `${rev.task?.project?.name || 'No Project'} • ${rev.task?.title || 'Revision'} • Revision ${vLabel}`;
|
||||
const revisionChargeQty = getRevisionChargeQuantity(rev?.version_number);
|
||||
const qty = revisionChargeQty > 0 ? revisionChargeQty : 1;
|
||||
const unitPrice = revisionChargeQty > 0 ? (price?.price || '') : 0;
|
||||
const desc = invBuildRevisionItemDescription(rev);
|
||||
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));
|
||||
@@ -226,14 +416,18 @@ export default function Invoices() {
|
||||
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 }));
|
||||
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 supabase.from('invoices').update({ status: 'sent' }).eq('id', invoice.id);
|
||||
} 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();
|
||||
navigate(`/invoices/${invoice.id}`);
|
||||
setViewingInvoice(createdInvoice);
|
||||
} catch (e) { alert(`Failed to save invoice: ${e.message || 'Unknown error'}`); }
|
||||
setInvSaving(false);
|
||||
};
|
||||
@@ -409,26 +603,7 @@ export default function Invoices() {
|
||||
};
|
||||
|
||||
const handleSendSubcontractorPO = async (po) => {
|
||||
const sentPO = 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'}`);
|
||||
}
|
||||
await updateSubcontractorPO(po, { status: 'sent', sent_at: new Date().toISOString() }, 'Failed to send PO');
|
||||
};
|
||||
|
||||
const handleReadyToPaySubcontractorPO = (po) => {
|
||||
@@ -490,6 +665,56 @@ export default function Invoices() {
|
||||
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) => {
|
||||
updateSubcontractorPO(po, { status: 'draft', paid_at: null, cancelled_at: null }, 'Failed to reopen PO');
|
||||
};
|
||||
@@ -622,7 +847,7 @@ export default function Invoices() {
|
||||
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Layout loading={pageLoading}>
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
{/* Top row: 60% chart + 40% stat cards */}
|
||||
{(() => {
|
||||
@@ -653,7 +878,7 @@ export default function Invoices() {
|
||||
<div style={{ ...CARD }}>
|
||||
<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>
|
||||
<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>)}
|
||||
</select>
|
||||
</div>
|
||||
@@ -694,7 +919,6 @@ export default function Invoices() {
|
||||
{ id: 'expenses', label: 'Expenses' },
|
||||
{ id: 'subcontractors', label: 'Subcontractors' },
|
||||
{ id: 'invoices', label: 'Invoices' },
|
||||
{ id: 'legacy', label: 'Legacy' },
|
||||
].map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
@@ -1075,7 +1299,7 @@ export default function Invoices() {
|
||||
return (
|
||||
<tr key={inv.id}>
|
||||
<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 || '—'}
|
||||
</button>
|
||||
</td>
|
||||
@@ -1195,7 +1419,7 @@ export default function Invoices() {
|
||||
<tbody>{sorted.map(inv => (
|
||||
<tr key={inv.id}>
|
||||
<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 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>
|
||||
@@ -1252,24 +1476,22 @@ export default function Invoices() {
|
||||
})()}
|
||||
|
||||
{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: 'expenses', label: 'EXPENSES' },
|
||||
{ id: 'sub-invoices', label: 'SUBCONTRACTOR INVOICES' },
|
||||
].map((t, i, arr) => (
|
||||
<span key={t.id} style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<button
|
||||
type="button"
|
||||
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' }}
|
||||
>{t.label}</button>
|
||||
{i < arr.length - 1 && <span style={{ margin: '0 10px', color: 'var(--border)', userSelect: 'none' }}>|</span>}
|
||||
</span>
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(t.id)}
|
||||
className={`section-tab-btn${activeTab === t.id ? ' is-active' : ''}`}
|
||||
>{t.label}</button>
|
||||
))}
|
||||
</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' }}>
|
||||
{companyNames.length > 0 && activeTab === 'invoices' && (
|
||||
<select className="filter-select" style={{ width: 120 }} value={filterCompany} onChange={e => setFilterCompany(e.target.value)}>
|
||||
@@ -1310,10 +1532,10 @@ export default function Invoices() {
|
||||
{loading ? (
|
||||
<p style={{ color: 'var(--text-muted)' }}>Loading...</p>
|
||||
) : 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)' }}>
|
||||
<table>
|
||||
<div className="table-wrapper">
|
||||
<table className="table-sticky-head">
|
||||
<thead>
|
||||
<tr>
|
||||
<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();
|
||||
return inv[key] || inv.company?.name || '';
|
||||
}).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.bill_to || inv.company?.name}</td>
|
||||
<td style={{ color: 'var(--text-muted)' }}>{new Date(inv.invoice_date).toLocaleDateString()}</td>
|
||||
@@ -1359,17 +1581,17 @@ export default function Invoices() {
|
||||
{activeTab === 'expenses' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
<div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
|
||||
{expensesLoading ? (
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Loading...</p>
|
||||
) : expensesError ? (
|
||||
<p style={{ color: 'var(--danger)', fontSize: 13 }}>{expensesError}</p>
|
||||
) : 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)' }}>
|
||||
<table>
|
||||
<div className="table-wrapper">
|
||||
<table className="table-sticky-head">
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="date" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle}>Date</SortTh>
|
||||
@@ -1431,16 +1653,16 @@ export default function Invoices() {
|
||||
|
||||
{activeTab === 'sub-invoices' && (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
{subInvoicesLoading ? (
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Loading...</p>
|
||||
) : subInvoicesError ? (
|
||||
<p style={{ color: 'var(--danger)', fontSize: 13 }}>{subInvoicesError}</p>
|
||||
) : 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)' }}>
|
||||
<table>
|
||||
<div className="table-wrapper">
|
||||
<table className="table-sticky-head">
|
||||
<thead>
|
||||
<tr>
|
||||
<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 subInvoiceStatusColor = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
|
||||
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>
|
||||
<div style={{ fontWeight: 400 }}>{inv.profile?.name || 'External'}</div>
|
||||
@@ -1488,9 +1710,139 @@ export default function Invoices() {
|
||||
)}
|
||||
</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 && (() => {
|
||||
const LABEL = { fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', 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 LABEL = { ...FIELD_LABEL_STYLE, marginBottom: 3 };
|
||||
const INPUT = FINANCE_MODAL_INPUT_STYLE;
|
||||
const isPdf = viewingExpense.receipt_name?.toLowerCase().endsWith('.pdf');
|
||||
const isDirty = expenseDetailEditing && (
|
||||
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={LABEL}>Expense Detail</div>
|
||||
{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 })}
|
||||
</div>
|
||||
)}
|
||||
@@ -1568,7 +1924,7 @@ export default function Invoices() {
|
||||
)}
|
||||
</div>
|
||||
{/* 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 ? (
|
||||
<>
|
||||
<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 && (() => {
|
||||
const LABEL = { fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', 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 LABEL = { ...FIELD_LABEL_STYLE, marginBottom: 3 };
|
||||
const INPUT = FINANCE_MODAL_INPUT_STYLE;
|
||||
return (
|
||||
<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()}>
|
||||
@@ -1601,7 +1957,11 @@ export default function Invoices() {
|
||||
{/* Left: fields */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, flex: '0 0 260px', overflowY: 'auto' }}>
|
||||
<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 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 }))} />
|
||||
@@ -1629,7 +1989,7 @@ export default function Invoices() {
|
||||
</div>
|
||||
</div>
|
||||
{/* 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="button" className="btn btn-outline" onClick={cancelExpenseEdit}>Cancel</button>
|
||||
</div>
|
||||
@@ -1642,8 +2002,8 @@ export default function Invoices() {
|
||||
</div>{/* end flex column wrapper */}
|
||||
|
||||
{showInvoiceForm && (() => {
|
||||
const LABEL = { fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', 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 LABEL = { ...FIELD_LABEL_STYLE, marginBottom: 3 };
|
||||
const INPUT = FINANCE_MODAL_INPUT_STYLE;
|
||||
return (
|
||||
<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()}>
|
||||
@@ -1672,11 +2032,11 @@ export default function Invoices() {
|
||||
</>}
|
||||
<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 style={{ marginTop: 'auto', paddingTop: 8 }}>
|
||||
<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>
|
||||
|
||||
@@ -1697,7 +2057,10 @@ export default function Invoices() {
|
||||
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>
|
||||
<div style={{ fontSize: 12 }}>{t.project?.name} • {t.title || t.service_type}</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>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{price ? `$${Number(price.price).toFixed(2)}` : 'No price'}</div>
|
||||
</div>
|
||||
<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>
|
||||
{invUnbilledRevisions.map(r => {
|
||||
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 (
|
||||
<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 style={{ fontSize: 12 }}>{r.task?.project?.name} • {r.task?.title} • R{String(r.version_number || 0).padStart(2, '0')}</div>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
@@ -1727,16 +2101,19 @@ export default function Invoices() {
|
||||
{/* Line items */}
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={LABEL}>Line Items</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '20px 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>)}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 60px 100px 90px 28px', gap: 6, marginBottom: 6 }}>
|
||||
{['', '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>
|
||||
{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>
|
||||
<input style={{ ...INPUT, padding: '4px 8px' }} type="text" placeholder="Description…" value={item.description} onChange={e => invUpdateItem(item.id, 'description', e.target.value)} />
|
||||
<input style={{ ...INPUT, padding: '4px 6px', textAlign: 'center' }} type="number" min="1" value={item.quantity} onChange={e => invUpdateItem(item.id, 'quantity', e.target.value)} />
|
||||
<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)} />
|
||||
<div style={{ textAlign: 'right', fontSize: 13, color: 'var(--text-primary)', paddingRight: 2 }}>${((Number(item.quantity) || 0) * (Number(item.unit_price) || 0)).toFixed(2)}</div>
|
||||
<span className={`badge ${item.submission_id ? 'badge-client_revision' : 'badge-initial'}`}>
|
||||
{item.submission_id ? 'Revision' : 'New'}
|
||||
</span>
|
||||
<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>
|
||||
</div>
|
||||
))}
|
||||
@@ -1746,7 +2123,7 @@ export default function Invoices() {
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import SortTh from '../../components/SortTh';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import PageLoader from '../../components/PageLoader';
|
||||
import LoadingButton from '../../components/LoadingButton';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { generateSubcontractorPOPDF } from '../../lib/invoice';
|
||||
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
|
||||
import { useSortable } from '../../hooks/useSortable';
|
||||
|
||||
const statusColor = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
|
||||
import SubcontractorInvoiceDetailView from '../../components/SubcontractorInvoiceDetailView';
|
||||
|
||||
export default function SubInvoiceDetail() {
|
||||
const { id } = useParams();
|
||||
@@ -17,6 +16,7 @@ export default function SubInvoiceDetail() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const { sortKey, sortDir, toggle, sort } = useSortable('description');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -25,18 +25,24 @@ export default function SubInvoiceDetail() {
|
||||
.select('*, profile:profiles!subcontractor_invoices_profile_id_fkey(id, name, email), items:subcontractor_invoice_items(*)')
|
||||
.eq('id', id)
|
||||
.single()
|
||||
.then(({ data }) => {
|
||||
setInvoice(data);
|
||||
.then(({ data, error: err }) => {
|
||||
if (err || !data) setError('Invoice not found.');
|
||||
setInvoice(data || null);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [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 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 === 'quantity') return Number(item.quantity || 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 '';
|
||||
});
|
||||
|
||||
@@ -58,15 +64,18 @@ export default function SubInvoiceDetail() {
|
||||
});
|
||||
|
||||
const handleMarkPaid = async () => {
|
||||
if (!invoice) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const paidAt = new Date().toISOString();
|
||||
const { error } = await supabase.from('subcontractor_invoices').update({ status: 'paid', paid_at: paidAt }).eq('id', id);
|
||||
if (error) throw error;
|
||||
const { error: updateError } = await supabase.from('subcontractor_invoices').update({ status: 'paid', paid_at: paidAt }).eq('id', id);
|
||||
if (updateError) throw updateError;
|
||||
const updated = { ...invoice, status: 'paid', paid_at: paidAt };
|
||||
setInvoice(updated);
|
||||
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) {
|
||||
try {
|
||||
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' }),
|
||||
}, attachments);
|
||||
} catch (emailErr) {
|
||||
alert(`Marked paid, but email failed: ${emailErr.message || 'unknown error'}`);
|
||||
setError(`Marked paid, but email failed: ${emailErr.message || 'unknown error'}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Failed to mark as paid: ${err.message}`);
|
||||
setError(`Failed to mark as paid: ${err.message}`);
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const handleReceipt = async () => {
|
||||
if (!invoice) return;
|
||||
setGenerating(true);
|
||||
try {
|
||||
await generateSubcontractorPOPDF(buildPDFArgs(invoice));
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
setError(err.message);
|
||||
}
|
||||
setGenerating(false);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!invoice) return;
|
||||
if (!window.confirm(`Delete invoice ${invoice.invoice_number}? This cannot be undone.`)) return;
|
||||
setSaving(true);
|
||||
const { error } = await supabase.from('subcontractor_invoices').delete().eq('id', id);
|
||||
if (error) { alert('Failed to delete: ' + error.message); setSaving(false); return; }
|
||||
navigate('/invoices', { state: { tab: 'sub-invoices' } });
|
||||
const { error: deleteError } = await supabase.from('subcontractor_invoices').delete().eq('id', id);
|
||||
if (deleteError) {
|
||||
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 (!invoice) return <Layout><p style={{ padding: 24 }}>Invoice not found.</p></Layout>;
|
||||
if (loading) return <Layout><PageLoader /></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 (
|
||||
<Layout>
|
||||
<button className="back-link" onClick={() => navigate('/invoices', { state: { tab: 'sub-invoices' } })}>
|
||||
← Back to Subcontractor Invoices
|
||||
</button>
|
||||
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">{invoice.invoice_number}</div>
|
||||
<div className="page-subtitle">
|
||||
{invoice.profile?.name || 'External'} · {invoice.profile?.email || '—'}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge
|
||||
status={statusColor[invoice.status] || 'not_started'}
|
||||
label={invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—'}
|
||||
/>
|
||||
</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>
|
||||
<SubcontractorInvoiceDetailView
|
||||
error={error}
|
||||
headerActions={headerActions}
|
||||
leftCardTitle="Submitted By"
|
||||
leftCardBody={(
|
||||
<>
|
||||
<div style={{ fontSize: 15, fontWeight: 400 }}>{invoice.profile?.name || 'External'}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 4 }}>{invoice.profile?.email || '—'}</div>
|
||||
</>
|
||||
)}
|
||||
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>
|
||||
)}
|
||||
{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>
|
||||
sortKey={sortKey}
|
||||
sortDir={sortDir}
|
||||
onSort={toggle}
|
||||
items={tableItems}
|
||||
notes={invoice.notes}
|
||||
total={total}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import PageLoader from '../../components/PageLoader';
|
||||
import LoadingButton from '../../components/LoadingButton';
|
||||
import SortTh from '../../components/SortTh';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { sendEmail } from '../../lib/email';
|
||||
import { generateSubcontractorPOPDF } from '../../lib/invoice';
|
||||
import { useSortable } from '../../hooks/useSortable';
|
||||
|
||||
@@ -73,49 +73,14 @@ export default function SubcontractorPODetail() {
|
||||
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 () => {
|
||||
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');
|
||||
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 () => {
|
||||
if (!window.confirm(`Resend ${po.po_number || 'this PO'} to ${po.profile?.email || 'the subcontractor'}?`)) return;
|
||||
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);
|
||||
}
|
||||
alert('PO emails are disabled.');
|
||||
};
|
||||
|
||||
const handleReadyToPay = () => updatePO({ status: 'ready_to_pay' }, 'Failed to mark ready to pay');
|
||||
@@ -136,7 +101,7 @@ export default function SubcontractorPODetail() {
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
navigate('/invoices', { state: { tab: 'subcontractor-po' } });
|
||||
navigate('/finances', { state: { tab: 'subcontractor-po' } });
|
||||
};
|
||||
|
||||
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>;
|
||||
|
||||
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 (
|
||||
<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>
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 SUPABASE_URL = Deno.env.get('SUPABASE_URL');
|
||||
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 TEAM_EMAILS = [
|
||||
'krao@fourgebranding.com',
|
||||
@@ -12,7 +13,7 @@ const TEAM_EMAILS = [
|
||||
'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];
|
||||
|
||||
// Types that only team members may trigger
|
||||
@@ -53,6 +54,8 @@ const optStr = (val: unknown, max = 500): string => {
|
||||
return val.trim();
|
||||
};
|
||||
|
||||
const optBool = (val: unknown): boolean => val === true;
|
||||
|
||||
/** Basic UUID format check. */
|
||||
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);
|
||||
@@ -102,26 +105,32 @@ serve(async (req) => {
|
||||
// ── 1. Authentication ────────────────────────────────────────────────────
|
||||
const authHeader = req.headers.get('Authorization') ?? '';
|
||||
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);
|
||||
}
|
||||
|
||||
// Use service role client to validate the token — works with ES256 JWTs
|
||||
const adminClient = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
|
||||
|
||||
const { data: userData, error: authError } = await adminClient.auth.getUser(accessToken);
|
||||
if (authError || !userData?.user) {
|
||||
return respond({ error: `Auth failed: ${authError?.message ?? 'no user'}` }, 401);
|
||||
let userData: { user?: { id: string } } | null = null;
|
||||
if (!isInternalWebhook) {
|
||||
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 ───────────────────────────────────────────────────────
|
||||
const { data: profile } = await adminClient
|
||||
.from('profiles')
|
||||
.select('role')
|
||||
.eq('id', userData.user.id)
|
||||
.single();
|
||||
|
||||
const role: string = profile?.role ?? '';
|
||||
const role: string = isInternalWebhook
|
||||
? 'team'
|
||||
: ((await adminClient
|
||||
.from('profiles')
|
||||
.select('role')
|
||||
.eq('id', userData?.user?.id)
|
||||
.single()).data?.role ?? '');
|
||||
if (!['team', 'client', 'external'].includes(role)) {
|
||||
return respond({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
@@ -163,17 +172,55 @@ serve(async (req) => {
|
||||
|
||||
subject = `New Request: ${esc(serviceType)} — ${esc(clientName)}`;
|
||||
html = `
|
||||
<h2>New Request Received</h2>
|
||||
<p><strong>From:</strong> ${esc(clientName)} (${esc(clientEmail)})</p>
|
||||
<p><strong>Company:</strong> ${esc(company) || '—'}</p>
|
||||
<p><strong>Service:</strong> ${esc(serviceType)}</p>
|
||||
<p><strong>Project:</strong> ${esc(projectName)}</p>
|
||||
${deadline ? `<p><strong>Deadline:</strong> ${esc(deadline)}</p>` : ''}
|
||||
<hr />
|
||||
<p><strong>Description:</strong></p>
|
||||
<p>${escMultiline(description)}</p>
|
||||
<br />
|
||||
<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>
|
||||
<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;">New Request Received</h2>
|
||||
<p style="color:#555;margin:0 0 24px;">A new request has been created in the portal.</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;">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)}`;
|
||||
html = `
|
||||
<h2>Revision Requested</h2>
|
||||
<p><strong>From:</strong> ${esc(clientName)}</p>
|
||||
<p><strong>Job:</strong> ${esc(serviceType)} — ${esc(projectName)}</p>
|
||||
<p><strong>New Version:</strong> ${esc(version)}</p>
|
||||
${deadline ? `<p><strong>New Deadline:</strong> ${esc(deadline)}</p>` : ''}
|
||||
<hr />
|
||||
<p><strong>Requested changes:</strong></p>
|
||||
<p>${escMultiline(description)}</p>
|
||||
<br />
|
||||
<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>
|
||||
<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;">Revision Requested</h2>
|
||||
<p style="color:#555;margin:0 0 24px;">A revision was requested in the portal.</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;">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 ────────────────────────────────────────────────
|
||||
const teamTypes = ['new_request', 'revision_submitted', 'client_approved', 'subcontractor_invoice_submitted'];
|
||||
const teamTypes = ['revision_submitted', 'client_approved'];
|
||||
let recipients: string[];
|
||||
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;
|
||||
} else {
|
||||
// Validate caller-supplied recipient list
|
||||
@@ -444,6 +681,10 @@ serve(async (req) => {
|
||||
|
||||
const senderEmail = optStr(data?.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') {
|
||||
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 webhookSecret = Deno.env.get('STRIPE_WEBHOOK_SECRET')!;
|
||||
const internalWebhookSecret = Deno.env.get('SUPABASE_WEBHOOK_SECRET')!;
|
||||
|
||||
serve(async (req) => {
|
||||
const body = await req.text();
|
||||
@@ -42,6 +43,31 @@ serve(async (req) => {
|
||||
};
|
||||
if (stripe_fee !== null) updateData.stripe_fee = stripe_fee;
|
||||
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
|
||||
|
||||
@@ -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
@@ -67,6 +67,9 @@ create table public.submissions (
|
||||
invoiced boolean not null default false,
|
||||
is_hot boolean not null default false,
|
||||
service_type text default '',
|
||||
sign_count integer,
|
||||
sign_family text default '',
|
||||
signs jsonb not null default '[]'::jsonb,
|
||||
deadline date,
|
||||
description text default '',
|
||||
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()
|
||||
returns text as $$
|
||||
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()
|
||||
returns boolean as $$
|
||||
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()
|
||||
returns uuid as $$
|
||||
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()
|
||||
returns bigint as $$
|
||||
@@ -326,7 +332,10 @@ begin
|
||||
|
||||
return new;
|
||||
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
|
||||
before update on public.tasks
|
||||
@@ -647,7 +656,8 @@ begin
|
||||
);
|
||||
return new;
|
||||
end;
|
||||
$$ language plpgsql security definer;
|
||||
$$ language plpgsql security definer
|
||||
set search_path = public;
|
||||
|
||||
create trigger on_auth_user_created
|
||||
after insert on auth.users
|
||||
|
||||
+1
-4
@@ -1,6 +1,3 @@
|
||||
{
|
||||
"rewrites": [{ "source": "/((?!\\.well-known).*)", "destination": "/index.html" }],
|
||||
"functions": {
|
||||
"api/backfill-request-files.js": { "maxDuration": 300 }
|
||||
}
|
||||
"rewrites": [{ "source": "/((?!\\.well-known).*)", "destination": "/index.html" }]
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user