Compare commits
71 Commits
283511bf3a
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 26ed7790b1 | |||
| 4c78b624f8 | |||
| 2cfbbec9dc | |||
| 1f52b6e96c | |||
| 2c1ff61b29 | |||
| bf70646a54 | |||
| 0d6ac3666e | |||
| 567c941151 | |||
| 6d8aa6bda4 | |||
| bcdb177244 | |||
| 91045a1897 | |||
| c91e292066 | |||
| c5f020cf44 | |||
| 8eacd86b04 | |||
| aff3d98929 | |||
| 1784af909d | |||
| 4ef73d193d | |||
| 6b9f008fff | |||
| c891f53d2a | |||
| 12c9a1a093 | |||
| 1cbf06a6c0 | |||
| 5b2d5b65a4 | |||
| da0e5d5c39 | |||
| 8b0cb31bc6 | |||
| 4faf0e80b6 | |||
| 623dcb7983 | |||
| 48c8fbb7ff | |||
| 1dd7ca1922 | |||
| fa8ade83d6 | |||
| e450f1b0d1 | |||
| e3b2df6e2c | |||
| e8513125b9 | |||
| 0945b548c1 | |||
| 6e4e99c33c | |||
| 04e0911e9f | |||
| 85625f4d95 | |||
| 71a8ed1b43 | |||
| 2c387c074f | |||
| 96e90561b9 | |||
| 65f10036d8 | |||
| aae5214cde | |||
| 36f1179c04 | |||
| 8b6e2739ae | |||
| 144bda426b | |||
| 959dd571d2 | |||
| e912f82520 | |||
| 894ec323f2 | |||
| 585fc7500c | |||
| d222d8b573 | |||
| 0d4b1f8096 | |||
| ae093a9b1c | |||
| e11ef45140 | |||
| 770bb1c05b | |||
| 1f09ce0a1d | |||
| 490e4f4bbf | |||
| 4fce77abb9 | |||
| 573c4a69fa | |||
| 30838e8da3 | |||
| 958f451836 | |||
| 986d831186 | |||
| ac3f08122d | |||
| 70ad8d0cef | |||
| 35d92d2176 | |||
| ab7a6b5a57 | |||
| 94d9c3904b | |||
| 5b3060e190 | |||
| 0b4705311b | |||
| 13ef1f4ded | |||
| 6fe7ab1059 | |||
| 8d0a461e7a | |||
| c9998d4a8d |
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "local-plugins",
|
||||||
|
"interface": {
|
||||||
|
"displayName": "Local Plugins"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "caveman",
|
||||||
|
"source": {
|
||||||
|
"source": "local",
|
||||||
|
"path": "./.codex-plugins/caveman"
|
||||||
|
},
|
||||||
|
"policy": {
|
||||||
|
"installation": "AVAILABLE",
|
||||||
|
"authentication": "ON_INSTALL"
|
||||||
|
},
|
||||||
|
"category": "Productivity"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,73 +1,40 @@
|
|||||||
{
|
{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(*)",
|
"Bash(npm run *)",
|
||||||
"Bash(\"/Users/kraohasanee/Documents/40-49 Fourge:*)",
|
"Bash(awk 'NR>=547 && /^}/ {print NR\": \"$0; c++; if\\(c>=1\\) exit}' src/pages/Companies.jsx)",
|
||||||
"Bash(vercel --version)",
|
"Bash(sed -n '545,548p' src/pages/Companies.jsx)",
|
||||||
"Bash(vercel --prod)",
|
"Bash(sed -i '' '545,715d' src/pages/Companies.jsx)",
|
||||||
"Bash(supabase --version)",
|
"Bash(sed -n '542,548p' src/pages/Companies.jsx)",
|
||||||
"Bash(brew install:*)",
|
"Bash(sed -n '76,80p' src/pages/team/TeamSubInvoiceDetail.jsx)",
|
||||||
"Read(//usr/local/bin/**)",
|
"Bash(sed -n '147p' src/pages/Profile.jsx)",
|
||||||
"Read(//Users/kraohasanee/.local/bin/**)",
|
"Bash(awk '{print $5, $9}')",
|
||||||
"Read(//usr/**)",
|
|
||||||
"Bash(command -v supabase)",
|
|
||||||
"Read(//Users/kraohasanee/Library/**)",
|
|
||||||
"Bash(npx supabase:*)",
|
|
||||||
"Bash(echo $PATH)",
|
|
||||||
"Bash(supabase functions:*)",
|
|
||||||
"Bash(npm install:*)",
|
|
||||||
"Bash(export PATH=\"/opt/homebrew/bin:$PATH\")",
|
|
||||||
"Read(//opt/homebrew/bin/**)",
|
|
||||||
"Read(//Users/kraohasanee/.npm/bin/**)",
|
|
||||||
"Bash(export PATH=\"/usr/local/bin:/opt/homebrew/bin:$PATH\")",
|
|
||||||
"Read(//Users/kraohasanee/.supabase/**)",
|
|
||||||
"Bash(supabase status:*)",
|
|
||||||
"Bash(supabase orgs:*)",
|
|
||||||
"Bash(supabase projects:*)",
|
|
||||||
"Bash(supabase db:*)",
|
|
||||||
"Bash(npm run:*)",
|
|
||||||
"Bash(npx vercel:*)",
|
|
||||||
"Bash(curl -vI https://portal.fourgebranding.com)",
|
|
||||||
"Bash(dig portal.fourgebranding.com A +short)",
|
|
||||||
"Bash(dig portal.fourgebranding.com CNAME +short)",
|
|
||||||
"Bash(curl:*)",
|
|
||||||
"Bash(supabase migration:*)",
|
|
||||||
"Bash(ls \"/Users/kraohasanee/Documents/40-49 Fourge Branding/41 Website/fourge-portal\"/.env*)",
|
|
||||||
"Bash(supabase secrets:*)",
|
|
||||||
"Bash(stripe version:*)",
|
|
||||||
"Bash(stripe config:*)",
|
|
||||||
"Bash(stripe checkout:*)",
|
|
||||||
"Bash(stripe payment_intents list --limit 10)",
|
|
||||||
"Bash(stripe charges:*)",
|
|
||||||
"Bash(vercel ls:*)",
|
|
||||||
"Bash(vercel promote:*)",
|
|
||||||
"Bash(vercel inspect:*)",
|
|
||||||
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(d.get\\('gitSource', d.get\\('meta', ''\\)\\)\\)\")",
|
|
||||||
"Bash(npx vite:*)",
|
|
||||||
"Bash(wait)",
|
|
||||||
"Bash(stripe webhook_endpoints list)",
|
|
||||||
"Bash(grep VITE_SUPABASE_ANON_KEY .env.local)",
|
|
||||||
"Bash(grep VITE_SUPABASE_ANON_KEY .env.production)",
|
|
||||||
"Bash(vercel whoami *)",
|
|
||||||
"Bash(vercel deploy *)",
|
|
||||||
"Bash(vercel build *)",
|
|
||||||
"Bash(vercel pull *)",
|
|
||||||
"Bash(sudo npm *)",
|
|
||||||
"Bash(npx vercel@latest --prod)",
|
|
||||||
"Bash(git add *)",
|
"Bash(git add *)",
|
||||||
"Bash(git commit -m ' *)",
|
"Bash(git commit -q -m ' *)",
|
||||||
"Bash(git push *)",
|
"Bash(git push *)",
|
||||||
"Bash(cat .env.local)",
|
"Bash(vercel --prod --yes)",
|
||||||
"Bash(cat .env)",
|
"Bash(sed -n '1,15p' src/pages/external/ExternalMyInvoices.jsx)",
|
||||||
"Bash(vercel env *)",
|
"Bash(sed -n '1,15p' src/pages/client/ClientMyInvoices.jsx)",
|
||||||
"Bash(python3 -m json.tool)",
|
"Bash(git commit -q -m 'fix: restore popupOverlayStyle import in ExternalMyInvoices *)",
|
||||||
"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: {}\"')",
|
"Bash(git commit -q -m 'style: increase card-bg alpha to match Safari rendering *)",
|
||||||
|
"Bash(git commit -q -m 'fix: subcontractor PDF title + multi-page support *)",
|
||||||
|
"Bash(git commit -q -m 'style: card-bg dark-based alpha to match Safari panel look *)",
|
||||||
|
"Bash(git commit -q -m 'style: white-based card-bg 0.07, blur 12px→20px for less transparency *)",
|
||||||
|
"Bash(git commit -q -m 'fix: subcontractor PDF quality, footer on all pages, header gap *)",
|
||||||
"mcp__plugin_supabase_supabase__execute_sql",
|
"mcp__plugin_supabase_supabase__execute_sql",
|
||||||
"mcp__plugin_supabase_supabase__apply_migration",
|
"Bash(sed -n '88,100p' src/pages/Tasks.jsx)",
|
||||||
"Bash(git commit *)",
|
"Bash(sed -n '570,600p' src/pages/Tasks.jsx)",
|
||||||
|
"Bash(git commit -q -m 'fix: hide counter on All tab in projects table *)",
|
||||||
|
"Bash(git commit -q -m 'fix: guard task status updates on invoice lifecycle transitions *)",
|
||||||
|
"Bash(vercel ls *)",
|
||||||
|
"Bash(vercel --prod)",
|
||||||
|
"Bash(git commit -q -m 'feat: add Sub Billing column to billing report \\(sub invoiced/paid per version\\) *)",
|
||||||
|
"Bash(git commit -q -m 'fix: report Version Status collapses Approved into Completed *)",
|
||||||
"mcp__plugin_supabase_supabase__list_projects",
|
"mcp__plugin_supabase_supabase__list_projects",
|
||||||
"WebFetch(domain:github.com)",
|
"Bash(grep -rni \"task_number\\\\|task #\\\\|task#\\\\|#\\\\${\\\\|taskNumber\\\\|seq\" src/pages/Tasks.jsx src/pages/TaskDetail.jsx src/pages/ProjectDetail.jsx)",
|
||||||
"Skill(supabase:supabase)"
|
"mcp__plugin_supabase_supabase__apply_migration",
|
||||||
|
"Bash(git commit -q -m 'fix: invoice picker skips review-shadow rows for service_type/pricing *)",
|
||||||
|
"Bash(git commit -q -m 'fix: fourge_error revisions always bill $0 to client *)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "caveman",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Ultra-compressed communication mode. Cut filler. Keep technical accuracy.",
|
||||||
|
"author": {
|
||||||
|
"name": "Julius Brussee",
|
||||||
|
"url": "https://github.com/JuliusBrussee"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/JuliusBrussee/caveman",
|
||||||
|
"repository": "https://github.com/JuliusBrussee/caveman",
|
||||||
|
"license": "MIT",
|
||||||
|
"keywords": [
|
||||||
|
"productivity",
|
||||||
|
"communication",
|
||||||
|
"brevity",
|
||||||
|
"writing"
|
||||||
|
],
|
||||||
|
"skills": "./skills/",
|
||||||
|
"interface": {
|
||||||
|
"displayName": "Caveman",
|
||||||
|
"shortDescription": "Talk like caveman. Cut filler. Keep technical accuracy.",
|
||||||
|
"longDescription": "Ultra-compressed communication mode for Codex. Use fewer words. Keep exact technical substance.",
|
||||||
|
"developerName": "Julius Brussee",
|
||||||
|
"category": "Productivity",
|
||||||
|
"capabilities": [
|
||||||
|
"Write"
|
||||||
|
],
|
||||||
|
"websiteURL": "https://github.com/JuliusBrussee/caveman",
|
||||||
|
"privacyPolicyURL": "https://github.com/JuliusBrussee/caveman/blob/main/README.md",
|
||||||
|
"termsOfServiceURL": "https://github.com/JuliusBrussee/caveman/blob/main/LICENSE",
|
||||||
|
"defaultPrompt": [
|
||||||
|
"Use caveman mode. Cut filler. Keep technical accuracy."
|
||||||
|
],
|
||||||
|
"composerIcon": "./assets/caveman-small.svg",
|
||||||
|
"logo": "./assets/caveman.svg",
|
||||||
|
"screenshots": [],
|
||||||
|
"brandColor": "#6B7280"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
---
|
||||||
|
name: caveman
|
||||||
|
description: >
|
||||||
|
Ultra-compressed communication mode. Cuts token usage ~75% by speaking like caveman
|
||||||
|
while keeping full technical accuracy. Supports intensity levels: lite, full (default), ultra,
|
||||||
|
wenyan-lite, wenyan-full, wenyan-ultra.
|
||||||
|
Use when user says "caveman mode", "talk like caveman", "use caveman", "less tokens",
|
||||||
|
"be brief", or invokes /caveman. Also auto-triggers when token efficiency is requested.
|
||||||
|
---
|
||||||
|
|
||||||
|
Respond terse like smart caveman. All technical substance stay. Only fluff die.
|
||||||
|
|
||||||
|
Default: **full**. Switch: `/caveman lite|full|ultra`.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). Technical terms exact. Code blocks unchanged. Errors quoted exact.
|
||||||
|
|
||||||
|
Pattern: `[thing] [action] [reason]. [next step].`
|
||||||
|
|
||||||
|
Not: "Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by..."
|
||||||
|
Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:"
|
||||||
|
|
||||||
|
## Intensity
|
||||||
|
|
||||||
|
| Level | What change |
|
||||||
|
|-------|------------|
|
||||||
|
| **lite** | No filler/hedging. Keep articles + full sentences. Professional but tight |
|
||||||
|
| **full** | Drop articles, fragments OK, short synonyms. Classic caveman |
|
||||||
|
| **ultra** | Abbreviate (DB/auth/config/req/res/fn/impl), strip conjunctions, arrows for causality (X → Y), one word when one word enough |
|
||||||
|
| **wenyan-lite** | Semi-classical. Drop filler/hedging but keep grammar structure, classical register |
|
||||||
|
| **wenyan-full** | Maximum classical terseness. Fully 文言文. 80-90% character reduction. Classical sentence patterns, verbs precede objects, subjects often omitted, classical particles (之/乃/為/其) |
|
||||||
|
| **wenyan-ultra** | Extreme abbreviation while keeping classical Chinese feel. Maximum compression, ultra terse |
|
||||||
|
|
||||||
|
Example — "Why React component re-render?"
|
||||||
|
- lite: "Your component re-renders because you create a new object reference each render. Wrap it in `useMemo`."
|
||||||
|
- full: "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`."
|
||||||
|
- ultra: "Inline obj prop → new ref → re-render. `useMemo`."
|
||||||
|
- wenyan-lite: "組件頻重繪,以每繪新生對象參照故。以 useMemo 包之。"
|
||||||
|
- wenyan-full: "物出新參照,致重繪。useMemo .Wrap之。"
|
||||||
|
- wenyan-ultra: "新參照→重繪。useMemo Wrap。"
|
||||||
|
|
||||||
|
Example — "Explain database connection pooling."
|
||||||
|
- lite: "Connection pooling reuses open connections instead of creating new ones per request. Avoids repeated handshake overhead."
|
||||||
|
- full: "Pool reuse open DB connections. No new connection per request. Skip handshake overhead."
|
||||||
|
- ultra: "Pool = reuse DB conn. Skip handshake → fast under load."
|
||||||
|
- wenyan-full: "池reuse open connection。不每req新開。skip handshake overhead。"
|
||||||
|
- wenyan-ultra: "池reuse conn。skip handshake → fast。"
|
||||||
|
|
||||||
|
## Auto-Clarity
|
||||||
|
|
||||||
|
Drop caveman for: security warnings, irreversible action confirmations, multi-step sequences where fragment order risks misread, user confused. Resume caveman after clear part done.
|
||||||
|
|
||||||
|
Example — destructive op:
|
||||||
|
> **Warning:** This will permanently delete all rows in the `users` table and cannot be undone.
|
||||||
|
> ```sql
|
||||||
|
> DROP TABLE users;
|
||||||
|
> ```
|
||||||
|
> Caveman resume. Verify backup exist first.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level persist until changed or session end.
|
||||||
@@ -11,6 +11,7 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
.env.backfill
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
# React + Vite
|
|
||||||
|
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
|
||||||
|
|
||||||
Currently, two official plugins are available:
|
|
||||||
|
|
||||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
|
||||||
|
|
||||||
## React Compiler
|
|
||||||
|
|
||||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
|
||||||
|
|
||||||
## Expanding the ESLint configuration
|
|
||||||
|
|
||||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
# Layout2 — Redesign Tracker
|
||||||
|
|
||||||
|
> Visual redesign only. **Functions stay identical.** No data flow, query, route, or
|
||||||
|
> behavior changes. If a change touches logic, it does not belong in Layout2.
|
||||||
|
|
||||||
|
- **Branch:** `redesign`
|
||||||
|
- **Dev:** http://localhost:5173/ (Vite HMR, no rebuild)
|
||||||
|
- **Live:** untouched — nothing deploys until explicitly approved. No `vercel --prod` during Layout2.
|
||||||
|
- **Started:** 2026-06-23
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
1. Redesign = layout, spacing, hierarchy, components' visual form. No prop/state/handler edits.
|
||||||
|
2. Keep every element that exists today (buttons, fields, badges) — reposition/restyle, don't remove function.
|
||||||
|
3. Preserve all routes, role gates (`team`/`external`/`client`), and conditional rendering as-is.
|
||||||
|
4. Reuse design tokens (below). Add new tokens rather than hard-coding values.
|
||||||
|
5. Commit per page/section with `redesign:` prefix so it's revertible.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Direction
|
||||||
|
|
||||||
|
- **Background:** solid 95% black `#0d0d0d`, no gradient, no animation.
|
||||||
|
- **Cards:** solid gray `#1a1a1a` (5% lighter than bg); elevated/hover `#242424`.
|
||||||
|
- **Font:** **Inter** (Google Fonts, weights 300–700) — modern, readable, app-wide. Brand `Fourge` font retired from UI text; logo is an image, unaffected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Control panel (single source of truth — `src/index.css :root`)
|
||||||
|
|
||||||
|
Change one line, every card/page across **all roles** updates.
|
||||||
|
|
||||||
|
| Knob | Token / line | Scope |
|
||||||
|
|---|---|---|
|
||||||
|
| Background | `--bg` | whole app |
|
||||||
|
| Card fill | `--card-bg` (+ `--card-bg-2` elevated/hover) | every card |
|
||||||
|
| Card border | `--card-border` (`1px solid transparent` = off) | every card |
|
||||||
|
| Card radius | `--card-radius` | every card |
|
||||||
|
| Font | body `font-family` (Inter) | whole app |
|
||||||
|
| Accent / gold | `--accent` (+ `--accent-hover`) | all gold UI |
|
||||||
|
| Status: error | `--danger` (+ `--danger-strong`) | all red |
|
||||||
|
| Status: success | `--success` (+ `--success-strong`) | success greens |
|
||||||
|
| Money/positive | `--positive` | dashboard/positive green |
|
||||||
|
| Info | `--info` | blue |
|
||||||
|
| Violet | `--violet` | purple |
|
||||||
|
| Warning | `--warning` | amber |
|
||||||
|
|
||||||
|
**Tints** pull from the same token: `color-mix(in srgb, var(--accent) 15%, transparent)`.
|
||||||
|
All semantic colors are theme-independent (badge light palette stays in `index.css`).
|
||||||
|
~168 hardcoded hex/rgba across 15 JSX files were swept to these tokens (zero visual change).
|
||||||
|
|
||||||
|
Cards = elements with `var(--card-bg)`; their frame uses `var(--card-border)`. The **menu/sidebar**
|
||||||
|
also uses `--card-border` + `--card-radius`. Inputs/tables/toolbar dividers keep their own `--border`
|
||||||
|
and are intentionally NOT card-controlled.
|
||||||
|
|
||||||
|
**Theming:** tokens live in `:root` (dark defaults); `[data-theme="light"]` overrides values.
|
||||||
|
Same token name site-wide, one value per theme. `--card-border` is set in both themes
|
||||||
|
(dark `#262626`, light `rgba(0,0,0,0.08)`); `--card-radius` is shared (color-free).
|
||||||
|
|
||||||
|
## Full token chart (canonical — `src/index.css`)
|
||||||
|
|
||||||
|
All in `:root` (dark); `[data-theme="light"]` overrides where noted. Same token name site-wide,
|
||||||
|
one value per theme. Solids = `var(--x)`; tints = `color-mix(in srgb, var(--x) N%, transparent)`.
|
||||||
|
|
||||||
|
### Surfaces
|
||||||
|
| Token | Dark | Light |
|
||||||
|
|---|---|---|
|
||||||
|
| `--bg` | `#1a1a1a` | `#ffffff` |
|
||||||
|
| `--card-bg` | `#1f1f1f` | `rgba(0,0,0,.02)` |
|
||||||
|
| `--card-bg-2` | `#262626` | `rgba(0,0,0,.08)` |
|
||||||
|
| `--surface-sunken` | `#141414` | `#f5f5f5` |
|
||||||
|
| `--popup-bg` | `var(--card-bg)` (= `#1f1f1f`) | `rgba(255,255,255,.92)` (opaque; light card-bg is translucent) |
|
||||||
|
|
||||||
|
### Borders
|
||||||
|
| Token | Dark | Light |
|
||||||
|
|---|---|---|
|
||||||
|
| `--border` | `rgba(245,165,35,.15)` | `rgba(0,0,0,.1)` |
|
||||||
|
| `--card-border` | `1px solid #262626` | `1px solid rgba(0,0,0,.08)` |
|
||||||
|
| `--card-radius` | `8px` | shared |
|
||||||
|
| `--interactive-hover-border` | `rgba(245,165,35,.3)` | `rgba(0,0,0,.2)` |
|
||||||
|
|
||||||
|
### Text
|
||||||
|
| Token | Dark | Light |
|
||||||
|
|---|---|---|
|
||||||
|
| `--text-primary` | `#ffffff` | `#0d0d0d` |
|
||||||
|
| `--text-secondary` | `#a8a8a8` | `rgba(0,0,0,.6)` |
|
||||||
|
| `--text-muted` | `#666666` | `rgba(0,0,0,.38)` |
|
||||||
|
| `--text-on-accent` | `#000000` | shared |
|
||||||
|
|
||||||
|
### Brand & status (theme-independent)
|
||||||
|
| Token | Value |
|
||||||
|
|---|---|
|
||||||
|
| `--accent` / `--accent-hover` | `#F5A523` / `#e09510` |
|
||||||
|
| `--danger` / `--danger-strong` | `#ef4444` / `#dc2626` |
|
||||||
|
| `--success` / `--success-strong` | `#22c55e` / `#16a34a` |
|
||||||
|
| `--positive` | `#4ade80` |
|
||||||
|
| `--info` | `#60a5fa` |
|
||||||
|
| `--violet` | `#a78bfa` |
|
||||||
|
| `--warning` | `#f59e0b` |
|
||||||
|
|
||||||
|
### Data / categorical (charts, file-type chips — by hue)
|
||||||
|
| Token | Value | Token | Value |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `--data-blue` | `#3b82f6` | `--data-indigo` | `#2563eb` |
|
||||||
|
| `--data-purple` | `#8b5cf6` | `--data-purple-soft` | `#c084fc` |
|
||||||
|
| `--data-pink` | `#ec4899` | `--data-pink-soft` | `#f472b6` |
|
||||||
|
| `--data-orange` | `#f97316` | `--data-orange-soft` | `#fb923c` |
|
||||||
|
| `--data-emerald` | `#10b981` | `--data-emerald-soft` | `#34d399` |
|
||||||
|
| `--data-gray` | `#6b7280` | | |
|
||||||
|
|
||||||
|
### Utility / misc
|
||||||
|
| Token | Dark | Light |
|
||||||
|
|---|---|---|
|
||||||
|
| `--overlay-scrim` | `rgba(0,0,0,.58)` | `rgba(255,255,255,.72)` |
|
||||||
|
| `--popup-shadow` | `0 24px 64px rgba(0,0,0,.5)` | lighter |
|
||||||
|
| `--avatar-inner-ring` | `#111111` | `#ffffff` |
|
||||||
|
| `--sidebar-*` | bg `#0d0d0d`, text `#888`, active `#fff`/`#1a1a1a` | inverted |
|
||||||
|
| motion | `--motion-fast 160ms`, `--motion-base 220ms`, ease `cubic-bezier(0.22,1,0.36,1)` | |
|
||||||
|
| buttons | h `22px`, radius `8px`, font `11px/500`, tracking `0.8px` | |
|
||||||
|
|
||||||
|
**Intentionally NOT tokenized (must stay literal):**
|
||||||
|
- Canvas/PDF export colors (`BrandBook.jsx`, `Converters.jsx` — `ctx.fillStyle`), white logo backgrounds.
|
||||||
|
- `PayInvoice.jsx` — public printable invoice, standalone light styling, must not follow app theme.
|
||||||
|
|
||||||
|
**Established patterns (from memory):**
|
||||||
|
- Card label color `rgba(255,255,255,0.8)`.
|
||||||
|
- Filter/tab layout: dropdowns *outside* card, tab buttons *inside* card above table. Canonical: `Tasks.jsx` (Requests).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shared shell / components
|
||||||
|
|
||||||
|
| Item | File | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| App shell / sidebar / nav | `src/components/Layout.jsx` | ⬜ |
|
||||||
|
| Global tokens & base CSS | `src/index.css` | ⬜ |
|
||||||
|
| StatusBadge | `src/components/StatusBadge.jsx` | ⬜ |
|
||||||
|
| FilterDropdown | `src/components/FilterDropdown.jsx` | ⬜ |
|
||||||
|
| SortTh (table headers) | `src/components/SortTh.jsx` | ⬜ |
|
||||||
|
| ProfileAvatar | `src/components/ProfileAvatar.jsx` | ⬜ |
|
||||||
|
| LoadingButton / PageLoader | `src/components/{LoadingButton,PageLoader}.jsx` | ⬜ |
|
||||||
|
| RequestForm | `src/components/RequestForm.jsx` | ⬜ |
|
||||||
|
| Invoice popups / tables | `InvoiceDetailPopup`, `InvoicePopupTable` | ⬜ |
|
||||||
|
| Subcontractor invoice form/view | `SubcontractorInvoice{Form,DetailView}.jsx` | ⬜ |
|
||||||
|
| FileAttachment | `src/components/FileAttachment.jsx` | ⬜ |
|
||||||
|
|
||||||
|
## Pages
|
||||||
|
|
||||||
|
| Route | Page | Roles | Status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/` | Login | all | ⬜ |
|
||||||
|
| `/dashboard` | `team/TeamDashboard.jsx` | team/external/client | ⬜ |
|
||||||
|
| `/tasks` | `Tasks.jsx` (Requests) | team/external/client | ⬜ |
|
||||||
|
| `/tasks/:id` | `TaskDetail.jsx` | team/external/client | ⬜ |
|
||||||
|
| `/projects/:id` | `ProjectDetail.jsx` | team/external/client | ⬜ |
|
||||||
|
| `/company` | `Companies.jsx` | team/client | ⬜ |
|
||||||
|
| `/company/:id` | `CompanyDetail.jsx` | team/client | ⬜ |
|
||||||
|
| `/finances` | `team/TeamInvoices.jsx` | team | ⬜ |
|
||||||
|
| `/finances/:id` | `team/TeamInvoiceDetail.jsx` | team | ⬜ |
|
||||||
|
| `/subcontractor-pos/new` | `team/TeamCreateSubcontractorPO.jsx` | team | ⬜ |
|
||||||
|
| `/subcontractor-pos/:id` | `team/TeamSubcontractorPODetail.jsx` | team | ⬜ |
|
||||||
|
| `/sub-invoices/:id` | `team/TeamSubInvoiceDetail.jsx` | team | ⬜ |
|
||||||
|
| `/reports` | `team/TeamReports.jsx` | team | ⬜ |
|
||||||
|
| `/fourge-passwords` | `team/TeamFourgePasswords.jsx` | team | ⬜ |
|
||||||
|
| `/survey-maker` | `SurveyMaker.jsx` | team/external | ⬜ |
|
||||||
|
| `/brand-book` | `BrandBook.jsx` | team/external | ⬜ |
|
||||||
|
| `/converters` | `Converters.jsx` | team/external | ⬜ |
|
||||||
|
| `/my-purchase-orders` | `external/ExternalMyPurchaseOrders.jsx` | external | ⬜ |
|
||||||
|
| `/subs-invoices` | `external/ExternalMyInvoices.jsx` | external | ⬜ |
|
||||||
|
| `/subs-invoices/new` | `external/ExternalMyInvoiceCreate.jsx` | external | ⬜ |
|
||||||
|
| `/subs-invoices/:id` | `external/ExternalMyInvoiceDetail.jsx` | external | ⬜ |
|
||||||
|
| `/client-invoices` | `client/ClientMyInvoices.jsx` | client | ⬜ |
|
||||||
|
| `/profile`, `/profile/:id` | `Profile.jsx` | all | ⬜ |
|
||||||
|
| `/pay/:id` | `PayInvoice.jsx` | public | ⬜ |
|
||||||
|
|
||||||
|
Legend: ⬜ todo · 🔄 in progress · ✅ done
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Log
|
||||||
|
|
||||||
|
- 2026-06-23 — Doc created, `redesign` branch cut, dev on 5173.
|
||||||
|
- 2026-06-23 — Base tokens: solid `#0d0d0d` bg, killed animated grid; cards `#1a1a1a`/`#242424`. Font → Inter.
|
||||||
|
- 2026-06-23 — bg → `#1a1a1a` (90% K), cards `#1f1f1f`/`#262626` (88% K), solid/no transparency.
|
||||||
|
- 2026-06-23 — Tokenized cards: added `--card-border` (off) + `--card-radius`; swept 28 inline card frames + `.card` class to the tokens. All cards now controllable from one line.
|
||||||
|
- 2026-06-23 — Card border on at `#262626` (85% K); light-mode `--card-border` = `rgba(0,0,0,0.08)`; menu/sidebar routed to `--card-border`/`--card-radius`.
|
||||||
|
- 2026-06-23 — Color system: added `--positive/--info/--violet/--warning/--danger-strong/--success-strong`; swept 168 hardcoded colors across 15 JSX files to tokens + `color-mix` tints. Site/role/page colors now single-source.
|
||||||
|
- 2026-06-23 — Completed palette: added `--data-*` (hue-named) categorical set, `--surface-sunken`, `--text-on-accent`. Swept file-type chips + chart colors + surface neutrals to tokens. Remaining literals are canvas/PDF exports + PayInvoice (intentional). Full chart documented above.
|
||||||
|
- 2026-06-23 — Popups single-source: `--popup-bg` now = `var(--card-bg)` (dark); stripped 8 inline `background: var(--card-bg)` overrides on `popupSurfaceStyle` so loader/modals/menus all use the token. Light stays opaque white.
|
||||||
|
- 2026-06-23 — Dashboard reflow: row2 = To Do (fluid) + Calendar (280px); row3 = Activity / In-Progress / Team Perf. New `.dash-row-todo` / `.dash-row-trio` classes; responsive (trio 2-up ≤1200, all stack ≤768). Retired bottomCardsHeight viewport-fill hack. Mobile-friendly is now a standing requirement for Layout2.
|
||||||
|
- 2026-06-23 — Perf sweep: root cause = ~400–770ms/query server cold-start (compute tier, infra). Code fixes: added FK/filter index migration (pushed to DB); removed redundant client/external scope waterfall in Tasks + Dashboard (rely on RLS, verified `has_company_access`/`project_members` parity); embedded submitter-profiles + deliveries into the Tasks submissions query (−2 round trips all roles). Test client/external dashboards on 5173.
|
||||||
|
- 2026-07-15 — Naming cleanup: the 91045a1 Project→Client rename was only partially walked back by later commits, leaving the project entity mislabeled "Client" in ~20 spots (Add Project modal, filters, PO/report/dashboard columns, etc.) while table headers already said "Project". Confirmed with user: **Company** = the customer org (e.g. "Public Storage"), **Project** = a job within that company — "Client" was a leftover label, not a third entity. Reverted all mislabeled text to "Project"; reverted the `/clients/:id` route back to canonical `/projects/:id` (nothing deployed to prod yet, so no live URLs broke). Also: removed stale pre-redesign `layout.md` (superseded by this doc), stopped tracking `.env.backfill` (had a live admin password/token committed — rotate those credentials separately).
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
export const FB_SOURCE = 'srv';
|
||||||
|
export const PROJECT_DEFAULT_SUBFOLDERS = ['00 Project Files'];
|
||||||
|
export const REQUEST_DEFAULT_SUBFOLDERS = ['Old Books', 'Working Files', 'Survey'];
|
||||||
|
|
||||||
|
export function normalizePath(path) {
|
||||||
|
const raw = String(path || '/').trim();
|
||||||
|
const parts = raw.split('/').filter(Boolean);
|
||||||
|
const clean = [];
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part === '.') continue;
|
||||||
|
if (part === '..') throw new Error('Invalid path: path traversal not allowed');
|
||||||
|
clean.push(part);
|
||||||
|
}
|
||||||
|
return `/${clean.join('/')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function joinPath(...parts) {
|
||||||
|
return normalizePath(parts.join('/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function safeName(value, fallback = '') {
|
||||||
|
const cleaned = String(value || '')
|
||||||
|
.trim()
|
||||||
|
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
return cleaned || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConfig() {
|
||||||
|
const url = String(process.env.FILEBROWSER_URL || '').trim().replace(/\/+$/, '');
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
token: process.env.FILEBROWSER_TOKEN || '',
|
||||||
|
clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients'),
|
||||||
|
subsRoot: normalizePath(process.env.FILEBROWSER_SUBS_ROOT || '/fourgebranding/team'),
|
||||||
|
configured: Boolean(url),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getToken(config) {
|
||||||
|
const token = String(config.token || '').trim();
|
||||||
|
if (!token) throw new Error('FILEBROWSER_TOKEN not configured');
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) {
|
||||||
|
const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString();
|
||||||
|
const url = `${config.url}${endpoint}?${qs}`;
|
||||||
|
const token = getToken(config);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { Authorization: `Bearer ${token}`, ...headers },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text().catch(() => '');
|
||||||
|
const error = new Error(text || `FileBrowser ${response.status}`);
|
||||||
|
error.status = response.status;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
try {
|
||||||
|
return text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAlreadyExistsError(error) {
|
||||||
|
const message = String(error?.message || '').toLowerCase();
|
||||||
|
return error?.status === 409 || message.includes('exist') || message.includes('conflict');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creates a single directory whose parent is already known to exist.
|
||||||
|
// Much cheaper than ensureDirectory, which re-walks the path from the root.
|
||||||
|
export async function createDirectory(config, fullPath) {
|
||||||
|
try {
|
||||||
|
await fbFetch(config, 'POST', '/api/resources', {
|
||||||
|
params: { path: normalizePath(fullPath), isDir: 'true' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (!isAlreadyExistsError(error)) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs tasks with bounded concurrency, preserving input order in the result.
|
||||||
|
export async function pooled(items, limit, worker) {
|
||||||
|
const results = new Array(items.length);
|
||||||
|
let cursor = 0;
|
||||||
|
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
||||||
|
while (cursor < items.length) {
|
||||||
|
const index = cursor;
|
||||||
|
cursor += 1;
|
||||||
|
results[index] = await worker(items[index], index);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(runners);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureDirectory(config, fullPath) {
|
||||||
|
const parts = normalizePath(fullPath).split('/').filter(Boolean);
|
||||||
|
let current = '/';
|
||||||
|
for (const part of parts) {
|
||||||
|
current = joinPath(current, part);
|
||||||
|
try {
|
||||||
|
await fbFetch(config, 'POST', '/api/resources', {
|
||||||
|
params: { path: current, isDir: 'true' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (!isAlreadyExistsError(error)) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns directory names at `path`, or null when the path itself does not exist.
|
||||||
|
export async function listDirectories(config, path) {
|
||||||
|
let payload;
|
||||||
|
try {
|
||||||
|
payload = await fbFetch(config, 'GET', '/api/resources', { params: { path: normalizePath(path) } });
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.status === 404) return null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
// This FileBrowser returns child directories under `folders`; older builds use a mixed `items` array.
|
||||||
|
if (Array.isArray(payload?.folders)) return payload.folders.map(folder => folder.name);
|
||||||
|
const items = payload?.items || [];
|
||||||
|
return items.filter(item => item.type === 'directory' || item.isDir).map(item => item.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAdminClient() {
|
||||||
|
const supabaseUrl = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL;
|
||||||
|
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||||
|
if (!supabaseUrl || !serviceKey) throw new Error('Supabase admin env not configured');
|
||||||
|
return createClient(supabaseUrl, serviceKey, {
|
||||||
|
auth: { persistSession: false, autoRefreshToken: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
import { createClient } from '@supabase/supabase-js';
|
|
||||||
|
|
||||||
const FB_SOURCE = 'files';
|
|
||||||
|
|
||||||
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 || '/fourgebranding/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(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
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' });
|
|
||||||
|
|
||||||
// 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 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = { processed: 0, skipped: 0, errors: [] };
|
|
||||||
|
|
||||||
for (const group of groups.values()) {
|
|
||||||
if (group.files.length === 0) { results.skipped++; continue; }
|
|
||||||
|
|
||||||
const revFolder = `R${String(group.versionNumber).padStart(2, '0')}`;
|
|
||||||
const companyDir = joinPath(config.clientRoot, safeName(group.companyName));
|
|
||||||
const projectDir = joinPath(companyDir, 'Projects', safeName(group.projectName));
|
|
||||||
const taskDir = joinPath(projectDir, safeName(group.taskTitle));
|
|
||||||
const requestInfoDir = joinPath(taskDir, 'Request Info');
|
|
||||||
const revDir = joinPath(requestInfoDir, revFolder);
|
|
||||||
|
|
||||||
// Ensure all parent dirs exist
|
|
||||||
await mkdir(config, companyDir);
|
|
||||||
await mkdir(config, joinPath(companyDir, 'Projects'));
|
|
||||||
await mkdir(config, projectDir);
|
|
||||||
await mkdir(config, taskDir);
|
|
||||||
await mkdir(config, requestInfoDir);
|
|
||||||
await mkdir(config, revDir);
|
|
||||||
|
|
||||||
for (const file of group.files) {
|
|
||||||
try {
|
|
||||||
// Get signed URL from Supabase Storage
|
|
||||||
const { data: signed, error: signedError } = await admin.storage
|
|
||||||
.from('submissions')
|
|
||||||
.createSignedUrl(file.storage_path, 60);
|
|
||||||
if (signedError || !signed?.signedUrl) {
|
|
||||||
results.errors.push(`signed url failed: ${file.storage_path}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Download file from Supabase Storage
|
|
||||||
const fileRes = await fetch(signed.signedUrl);
|
|
||||||
if (!fileRes.ok) {
|
|
||||||
results.errors.push(`download failed: ${file.name}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const fileBuffer = await fileRes.arrayBuffer();
|
|
||||||
|
|
||||||
// Upload to FileBrowser
|
|
||||||
const fbFilePath = joinPath(revDir, file.name);
|
|
||||||
await fbFetch(config, 'POST', '/api/resources', {
|
|
||||||
params: { path: fbFilePath, override: 'true' },
|
|
||||||
headers: { 'Content-Type': 'application/octet-stream' },
|
|
||||||
body: fileBuffer,
|
|
||||||
});
|
|
||||||
|
|
||||||
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';
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
const FB_SOURCE = 'files';
|
|
||||||
|
|
||||||
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 || '/fourgebranding/Clients'),
|
|
||||||
archiveRoot: normalizePath(process.env.FILEBROWSER_ARCHIVE_ROOT || '/fourgebranding/Archive'),
|
|
||||||
configured: Boolean(url) && Boolean(process.env.FILEBROWSER_TOKEN),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) {
|
|
||||||
const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString();
|
|
||||||
const res = await fetch(`${config.url}${endpoint}?${qs}`, {
|
|
||||||
method,
|
|
||||||
headers: { Authorization: `Bearer ${config.token}`, ...headers },
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text();
|
|
||||||
const err = new Error(text || `FileBrowser ${res.status}`);
|
|
||||||
err.status = res.status;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
const text = await res.text();
|
|
||||||
try { return text ? JSON.parse(text) : null; } catch { return text; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fbExists(config, path) {
|
|
||||||
try { await fbFetch(config, 'GET', '/api/resources', { params: { path } }); return true; }
|
|
||||||
catch (e) { if (e.status === 404) return false; throw e; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fbMkdir(config, path) {
|
|
||||||
await fbFetch(config, 'POST', '/api/resources', { params: { path, isDir: 'true' } }).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function mergeMove(config, fbSrc, fbDstParent) {
|
|
||||||
const name = basename(fbSrc);
|
|
||||||
const fbDst = joinPath(fbDstParent, name);
|
|
||||||
const destExists = await fbExists(config, fbDst);
|
|
||||||
if (!destExists) {
|
|
||||||
await fbFetch(config, 'PATCH', '/api/resources', {
|
|
||||||
params: {},
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
action: 'move',
|
|
||||||
items: [{ fromSource: FB_SOURCE, fromPath: fbSrc, toSource: FB_SOURCE, toPath: fbDst }],
|
|
||||||
overwrite: false,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const data = await fbFetch(config, 'GET', '/api/resources', { params: { path: fbSrc } });
|
|
||||||
const dirs = (data?.folders || []).map(f => f.name);
|
|
||||||
const files = (data?.files || []).map(f => f.name);
|
|
||||||
await fbMkdir(config, fbDst);
|
|
||||||
for (const dir of dirs) await mergeMove(config, joinPath(fbSrc, dir), fbDst);
|
|
||||||
for (const file of files) {
|
|
||||||
await fbFetch(config, 'PATCH', '/api/resources', {
|
|
||||||
params: {},
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
action: 'move',
|
|
||||||
items: [{ fromSource: FB_SOURCE, fromPath: joinPath(fbSrc, file), toSource: FB_SOURCE, toPath: joinPath(fbDst, file) }],
|
|
||||||
overwrite: true,
|
|
||||||
}),
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
await fbFetch(config, 'DELETE', '/api/resources', { params: { path: fbSrc } }).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function archiveProject(companyName, projectName) {
|
|
||||||
const config = getFbConfig();
|
|
||||||
if (!config.configured || !companyName || !projectName) return;
|
|
||||||
const co = safeName(companyName);
|
|
||||||
const proj = safeName(projectName);
|
|
||||||
// Ensure archive dirs exist
|
|
||||||
await fbMkdir(config, config.archiveRoot);
|
|
||||||
await fbMkdir(config, joinPath(config.archiveRoot, 'Clients'));
|
|
||||||
await fbMkdir(config, joinPath(config.archiveRoot, 'Clients', co));
|
|
||||||
await fbMkdir(config, joinPath(config.archiveRoot, 'Clients', co, 'Projects'));
|
|
||||||
// Merge-move project folder into archive
|
|
||||||
const srcPath = joinPath(config.clientRoot, co, 'Projects', proj);
|
|
||||||
const dstParentPath = joinPath(config.archiveRoot, 'Clients', co, 'Projects');
|
|
||||||
await mergeMove(config, srcPath, dstParentPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function handler(req, res) {
|
export default async function handler(req, res) {
|
||||||
if (req.method !== 'DELETE') return res.status(405).json({ error: 'Method not allowed' });
|
if (req.method !== 'DELETE') return res.status(405).json({ error: 'Method not allowed' });
|
||||||
|
|
||||||
@@ -152,22 +27,11 @@ export default async function handler(req, res) {
|
|||||||
auth: { persistSession: false, autoRefreshToken: false },
|
auth: { persistSession: false, autoRefreshToken: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch project + company name before deletion (needed for archive path)
|
|
||||||
const { data: projRecord } = await admin
|
|
||||||
.from('projects')
|
|
||||||
.select('id, name, company:companies(name)')
|
|
||||||
.eq('id', projectId)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (!projRecord) return res.status(404).json({ error: 'Project not found' });
|
|
||||||
|
|
||||||
// For clients, confirm they can see this project (RLS gate)
|
|
||||||
if (profile.role === 'client') {
|
if (profile.role === 'client') {
|
||||||
const { data: proj, error: projErr } = await callerClient.from('projects').select('id').eq('id', projectId).single();
|
const { data: proj, error: projErr } = await callerClient.from('projects').select('id').eq('id', projectId).single();
|
||||||
if (projErr || !proj) return res.status(404).json({ error: 'Project not found or access denied' });
|
if (projErr || !proj) return res.status(404).json({ error: 'Project not found or access denied' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup storage files
|
|
||||||
const { data: tasks } = await admin.from('tasks').select('id').eq('project_id', projectId);
|
const { data: tasks } = await admin.from('tasks').select('id').eq('project_id', projectId);
|
||||||
const taskIds = (tasks || []).map(t => t.id);
|
const taskIds = (tasks || []).map(t => t.id);
|
||||||
|
|
||||||
@@ -190,17 +54,8 @@ export default async function handler(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete project (DB cascade handles tasks/submissions/etc.)
|
|
||||||
const { error } = await admin.from('projects').delete().eq('id', projectId);
|
const { error } = await admin.from('projects').delete().eq('id', projectId);
|
||||||
if (error) return res.status(500).json({ error: error.message });
|
if (error) return res.status(500).json({ error: error.message });
|
||||||
|
|
||||||
// Archive FileBrowser project folder (server-side, so errors are logged)
|
|
||||||
try {
|
|
||||||
await archiveProject(projRecord.company?.name, projRecord.name);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[delete-project] archive failed:', e.message);
|
|
||||||
// Don't fail — DB delete succeeded
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(200).json({ ok: true });
|
return res.status(200).json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,126 +1,5 @@
|
|||||||
import { createClient } from '@supabase/supabase-js';
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
const FB_SOURCE = 'files';
|
|
||||||
|
|
||||||
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 || '/fourgebranding/Clients'),
|
|
||||||
archiveRoot: normalizePath(process.env.FILEBROWSER_ARCHIVE_ROOT || '/fourgebranding/Archive'),
|
|
||||||
configured: Boolean(url) && Boolean(process.env.FILEBROWSER_TOKEN),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) {
|
|
||||||
const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString();
|
|
||||||
const res = await fetch(`${config.url}${endpoint}?${qs}`, {
|
|
||||||
method,
|
|
||||||
headers: { Authorization: `Bearer ${config.token}`, ...headers },
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text();
|
|
||||||
const err = new Error(text || `FileBrowser ${res.status}`);
|
|
||||||
err.status = res.status;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
const text = await res.text();
|
|
||||||
try { return text ? JSON.parse(text) : null; } catch { return text; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fbExists(config, path) {
|
|
||||||
try { await fbFetch(config, 'GET', '/api/resources', { params: { path } }); return true; }
|
|
||||||
catch (e) { if (e.status === 404) return false; throw e; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fbMkdir(config, path) {
|
|
||||||
await fbFetch(config, 'POST', '/api/resources', { params: { path, isDir: 'true' } }).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function mergeMove(config, fbSrc, fbDstParent) {
|
|
||||||
const name = basename(fbSrc);
|
|
||||||
const fbDst = joinPath(fbDstParent, name);
|
|
||||||
const destExists = await fbExists(config, fbDst);
|
|
||||||
if (!destExists) {
|
|
||||||
await fbFetch(config, 'PATCH', '/api/resources', {
|
|
||||||
params: {},
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
action: 'move',
|
|
||||||
items: [{ fromSource: FB_SOURCE, fromPath: fbSrc, toSource: FB_SOURCE, toPath: fbDst }],
|
|
||||||
overwrite: false,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const data = await fbFetch(config, 'GET', '/api/resources', { params: { path: fbSrc } });
|
|
||||||
const dirs = (data?.folders || []).map(f => f.name);
|
|
||||||
const files = (data?.files || []).map(f => f.name);
|
|
||||||
await fbMkdir(config, fbDst);
|
|
||||||
for (const dir of dirs) await mergeMove(config, joinPath(fbSrc, dir), fbDst);
|
|
||||||
for (const file of files) {
|
|
||||||
await fbFetch(config, 'PATCH', '/api/resources', {
|
|
||||||
params: {},
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
action: 'move',
|
|
||||||
items: [{ fromSource: FB_SOURCE, fromPath: joinPath(fbSrc, file), toSource: FB_SOURCE, toPath: joinPath(fbDst, file) }],
|
|
||||||
overwrite: true,
|
|
||||||
}),
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
await fbFetch(config, 'DELETE', '/api/resources', { params: { path: fbSrc } }).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function archiveTask(companyName, projectName, taskTitle) {
|
|
||||||
const config = getFbConfig();
|
|
||||||
if (!config.configured || !companyName || !projectName || !taskTitle) return;
|
|
||||||
const co = safeName(companyName);
|
|
||||||
const proj = safeName(projectName);
|
|
||||||
const task = safeName(taskTitle);
|
|
||||||
// Ensure archive dirs exist
|
|
||||||
await fbMkdir(config, config.archiveRoot);
|
|
||||||
await fbMkdir(config, joinPath(config.archiveRoot, 'Clients'));
|
|
||||||
await fbMkdir(config, joinPath(config.archiveRoot, 'Clients', co));
|
|
||||||
await fbMkdir(config, joinPath(config.archiveRoot, 'Clients', co, 'Projects'));
|
|
||||||
await fbMkdir(config, joinPath(config.archiveRoot, 'Clients', co, 'Projects', proj));
|
|
||||||
// Merge-move task folder into archive
|
|
||||||
const srcPath = joinPath(config.clientRoot, co, 'Projects', proj, task);
|
|
||||||
const dstParentPath = joinPath(config.archiveRoot, 'Clients', co, 'Projects', proj);
|
|
||||||
await mergeMove(config, srcPath, dstParentPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function handler(req, res) {
|
export default async function handler(req, res) {
|
||||||
if (req.method !== 'DELETE') return res.status(405).json({ error: 'Method not allowed' });
|
if (req.method !== 'DELETE') return res.status(405).json({ error: 'Method not allowed' });
|
||||||
|
|
||||||
@@ -148,16 +27,9 @@ export default async function handler(req, res) {
|
|||||||
auth: { persistSession: false, autoRefreshToken: false },
|
auth: { persistSession: false, autoRefreshToken: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch task + project + company before deletion using explicit joins
|
const { data: taskRecord } = await admin.from('tasks').select('id').eq('id', taskId).single();
|
||||||
const { data: taskRecord } = await admin.from('tasks').select('id, title, project_id').eq('id', taskId).single();
|
|
||||||
if (!taskRecord) return res.status(404).json({ error: 'Task not found' });
|
if (!taskRecord) return res.status(404).json({ error: 'Task not found' });
|
||||||
|
|
||||||
const { data: projectRecord } = await admin.from('projects').select('id, name, company_id').eq('id', taskRecord.project_id).single();
|
|
||||||
const { data: companyRecord } = projectRecord?.company_id
|
|
||||||
? await admin.from('companies').select('id, name').eq('id', projectRecord.company_id).single()
|
|
||||||
: { data: null };
|
|
||||||
|
|
||||||
// Cleanup storage files
|
|
||||||
const { data: subs } = await admin.from('submissions').select('id').eq('task_id', taskId);
|
const { data: subs } = await admin.from('submissions').select('id').eq('task_id', taskId);
|
||||||
const subIds = (subs || []).map(s => s.id);
|
const subIds = (subs || []).map(s => s.id);
|
||||||
|
|
||||||
@@ -175,18 +47,8 @@ export default async function handler(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete task (DB cascade handles submissions/etc.)
|
|
||||||
const { error } = await admin.from('tasks').delete().eq('id', taskId);
|
const { error } = await admin.from('tasks').delete().eq('id', taskId);
|
||||||
if (error) return res.status(500).json({ error: error.message });
|
if (error) return res.status(500).json({ error: error.message });
|
||||||
|
|
||||||
// Archive FileBrowser task folder
|
return res.status(200).json({ ok: true });
|
||||||
let archiveError = null;
|
|
||||||
try {
|
|
||||||
await archiveTask(companyRecord?.name, projectRecord?.name, taskRecord.title);
|
|
||||||
} catch (e) {
|
|
||||||
archiveError = e.message;
|
|
||||||
console.error('[delete-task] archive failed:', e.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(200).json({ ok: true, archiveError });
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { createClient } from '@supabase/supabase-js';
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
const FB_SOURCE = 'files';
|
const FB_SOURCE = 'srv';
|
||||||
|
const PROJECT_DEFAULT_SUBFOLDERS = ['00 Project Files'];
|
||||||
|
const REQUEST_DEFAULT_SUBFOLDERS = ['Old Books', 'Working Files', 'Survey'];
|
||||||
|
|
||||||
|
export const config = { api: { bodyParser: false, sizeLimit: '50mb' } };
|
||||||
|
|
||||||
function json(res, status, body) {
|
function json(res, status, body) {
|
||||||
res.status(status).setHeader('Content-Type', 'application/json');
|
res.status(status).setHeader('Content-Type', 'application/json');
|
||||||
@@ -24,17 +28,6 @@ function joinPath(...parts) {
|
|||||||
return normalizePath(parts.join('/'));
|
return normalizePath(parts.join('/'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function parentDir(path) {
|
|
||||||
const parts = normalizePath(path).split('/').filter(Boolean);
|
|
||||||
parts.pop();
|
|
||||||
return `/${parts.join('/')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function basename(path) {
|
|
||||||
const parts = normalizePath(path).split('/').filter(Boolean);
|
|
||||||
return parts[parts.length - 1] || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeName(value, fallback = '') {
|
function safeName(value, fallback = '') {
|
||||||
const cleaned = String(value || '')
|
const cleaned = String(value || '')
|
||||||
.trim()
|
.trim()
|
||||||
@@ -44,44 +37,55 @@ function safeName(value, fallback = '') {
|
|||||||
return cleaned || fallback;
|
return cleaned || fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readBody(req) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks = [];
|
||||||
|
req.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
|
||||||
|
req.on('end', () => resolve(Buffer.concat(chunks)));
|
||||||
|
req.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function getConfig() {
|
function getConfig() {
|
||||||
const url = String(process.env.FILEBROWSER_URL || '').trim().replace(/\/+$/, '');
|
const url = String(process.env.FILEBROWSER_URL || '').trim().replace(/\/+$/, '');
|
||||||
return {
|
return {
|
||||||
url,
|
url,
|
||||||
token: process.env.FILEBROWSER_TOKEN || '',
|
token: process.env.FILEBROWSER_TOKEN || '',
|
||||||
teamRoot: normalizePath(process.env.FILEBROWSER_TEAM_ROOT || '/'),
|
|
||||||
clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients'),
|
clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients'),
|
||||||
externalSubsRoot: normalizePath(process.env.FILEBROWSER_SUBS_ROOT || '/fourgebranding/Subcontractors'),
|
subsRoot: normalizePath(process.env.FILEBROWSER_SUBS_ROOT || '/fourgebranding/team'),
|
||||||
externalClientsRoot: normalizePath(process.env.FILEBROWSER_CLIENTS_ROOT || '/fourgebranding/Clients'),
|
|
||||||
configured: Boolean(url),
|
configured: Boolean(url),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getToken(config) {
|
function getToken(config) {
|
||||||
if (!config.token) throw new Error('FILEBROWSER_TOKEN not configured');
|
const token = String(config.token || '').trim();
|
||||||
return config.token;
|
if (!token) throw new Error('FILEBROWSER_TOKEN not configured');
|
||||||
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) {
|
async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) {
|
||||||
const token = getToken(config);
|
|
||||||
const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString();
|
const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString();
|
||||||
const url = `${config.url}${endpoint}?${qs}`;
|
const url = `${config.url}${endpoint}?${qs}`;
|
||||||
|
const token = getToken(config);
|
||||||
const res = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method,
|
method,
|
||||||
headers: { Authorization: `Bearer ${token}`, ...headers },
|
headers: { Authorization: `Bearer ${token}`, ...headers },
|
||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!response.ok) {
|
||||||
const text = await res.text();
|
const text = await response.text().catch(() => '');
|
||||||
const err = new Error(text || `FileBrowser ${res.status}`);
|
const error = new Error(text || `FileBrowser ${response.status}`);
|
||||||
err.status = res.status;
|
error.status = response.status;
|
||||||
throw err;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = await res.text();
|
const text = await response.text();
|
||||||
try { return text ? JSON.parse(text) : null; } catch { return text; }
|
try {
|
||||||
|
return text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createCallerClient(authHeader) {
|
async function createCallerClient(authHeader) {
|
||||||
@@ -101,17 +105,16 @@ async function requirePortalUser(authHeader) {
|
|||||||
|
|
||||||
const { data: profile, error: profileError } = await callerClient
|
const { data: profile, error: profileError } = await callerClient
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select('id, name, role, company:companies(id, name)')
|
.select('id, role, company:companies(id, name)')
|
||||||
.eq('id', userData.user.id)
|
.eq('id', userData.user.id)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (profileError) return { ok: false, status: 500, message: profileError.message };
|
if (profileError) return { ok: false, status: 500, message: profileError.message };
|
||||||
if (!['team', 'external', 'client'].includes(profile?.role)) {
|
if (!['team', 'client'].includes(profile?.role)) {
|
||||||
return { ok: false, status: 403, message: 'Forbidden' };
|
return { ok: false, status: 403, message: 'Forbidden' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mirror AuthContext: load all companies (FK + company_members)
|
const clientCompanies = [];
|
||||||
let clientCompanies = [];
|
|
||||||
if (profile.role === 'client') {
|
if (profile.role === 'client') {
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
if (profile.company?.id) {
|
if (profile.company?.id) {
|
||||||
@@ -122,340 +125,275 @@ async function requirePortalUser(authHeader) {
|
|||||||
.from('company_members')
|
.from('company_members')
|
||||||
.select('company:companies(id, name)')
|
.select('company:companies(id, name)')
|
||||||
.eq('profile_id', userData.user.id);
|
.eq('profile_id', userData.user.id);
|
||||||
for (const m of memberships || []) {
|
for (const row of memberships || []) {
|
||||||
if (m.company?.id && !seen.has(m.company.id)) {
|
if (row.company?.id && !seen.has(row.company.id)) {
|
||||||
clientCompanies.push(m.company);
|
seen.add(row.company.id);
|
||||||
seen.add(m.company.id);
|
clientCompanies.push(row.company);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ok: true, callerClient, profile: { ...profile, clientCompanies, email: userData.user.email } };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getExternalProjects(callerClient, userId) {
|
|
||||||
const { data, error } = await callerClient
|
|
||||||
.from('project_members')
|
|
||||||
.select('project:projects(id, name, company:companies(name))')
|
|
||||||
.eq('profile_id', userId);
|
|
||||||
|
|
||||||
if (error) throw new Error(error.message);
|
|
||||||
return (data || []).map(r => r.project).filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveUserRoot(config, profile) {
|
|
||||||
if (profile.role === 'team') return config.teamRoot;
|
|
||||||
if (profile.role === 'client') {
|
|
||||||
const companyFolder = safeName(profile.company?.name, profile.id);
|
|
||||||
return joinPath(config.clientRoot, companyFolder);
|
|
||||||
}
|
|
||||||
if (profile.role === 'external') return config.externalSubsRoot;
|
|
||||||
return '/';
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveClientPath(config, vPath, companies) {
|
|
||||||
const parts = normalizePath(vPath).split('/').filter(Boolean);
|
|
||||||
if (parts.length === 0) return { virtual: true };
|
|
||||||
|
|
||||||
const companyFolder = parts[0];
|
|
||||||
const match = companies.find(c => safeName(c.name, '') === companyFolder);
|
|
||||||
if (!match) {
|
|
||||||
const err = new Error('Access denied');
|
|
||||||
err.status = 403;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rest = parts.slice(1);
|
|
||||||
const base = joinPath(config.clientRoot, companyFolder);
|
|
||||||
const fbPath = rest.length > 0 ? joinPath(base, ...rest) : base;
|
|
||||||
return { virtual: false, fbPath };
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildClientVirtualEntries(companies) {
|
|
||||||
return companies.map(c => {
|
|
||||||
const name = safeName(c.name, c.id);
|
|
||||||
return { name, type: 'dir', size: 0, mtime: null, path: `/${name}` };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveExternalPath(config, vPath, profile, projects) {
|
|
||||||
const myFolder = safeName(profile.name, profile.id);
|
|
||||||
const parts = normalizePath(vPath).split('/').filter(Boolean);
|
|
||||||
|
|
||||||
if (parts.length === 0) return { virtual: true };
|
|
||||||
|
|
||||||
// Their personal Team folder
|
|
||||||
if (parts[0] === myFolder) {
|
|
||||||
const fbPath = joinPath(config.externalSubsRoot, ...parts);
|
|
||||||
return { virtual: false, fbPath };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assigned client projects — flattened: Projects/{project}/...
|
|
||||||
if (parts[0] === 'Projects') {
|
|
||||||
if (parts.length < 2) return { virtual: true };
|
|
||||||
const [, projectFolder, ...rest] = parts;
|
|
||||||
const match = projects.find(p => safeName(p.name, '') === projectFolder);
|
|
||||||
if (!match) {
|
|
||||||
const err = new Error('Access denied to this project');
|
|
||||||
err.status = 403;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
const company = safeName(match.company?.name, '');
|
|
||||||
const base = joinPath(config.externalClientsRoot, company, 'Projects', projectFolder);
|
|
||||||
const fbPath = rest.length > 0 ? joinPath(base, ...rest) : base;
|
|
||||||
return { virtual: false, fbPath };
|
|
||||||
}
|
|
||||||
|
|
||||||
const err = new Error('Access denied');
|
|
||||||
err.status = 403;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildExternalVirtualEntries(vPath, profile, projects) {
|
|
||||||
const myFolder = safeName(profile.name, profile.id);
|
|
||||||
const parts = normalizePath(vPath).split('/').filter(Boolean);
|
|
||||||
|
|
||||||
if (parts.length === 0) {
|
|
||||||
const entries = [{ name: myFolder, type: 'dir', size: 0, mtime: null, path: `/${myFolder}` }];
|
|
||||||
if (projects.length > 0) entries.push({ name: 'Projects', type: 'dir', size: 0, mtime: null, path: '/Projects' });
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parts[0] === 'Projects' && parts.length === 1) {
|
|
||||||
const seen = new Set();
|
|
||||||
return projects
|
|
||||||
.map(p => safeName(p.name, ''))
|
|
||||||
.filter(name => name && !seen.has(name) && seen.add(name))
|
|
||||||
.map(name => ({ name, type: 'dir', size: 0, mtime: null, path: `/Projects/${name}` }));
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeQuantumItems(data, virtualPath) {
|
|
||||||
const dirs = (data?.folders || []).map(item => ({ ...item, _type: 'directory' }));
|
|
||||||
const files = (data?.files || []).map(item => ({ ...item, _type: item.type || 'file' }));
|
|
||||||
const items = [...dirs, ...files].map(item => ({
|
|
||||||
name: item.name,
|
|
||||||
type: (item._type === 'directory' || item.type === 'directory') ? 'dir' : 'file',
|
|
||||||
size: (item._type === 'directory' || item.type === 'directory') ? 0 : (item.size || 0),
|
|
||||||
mtime: item.modified || null,
|
|
||||||
path: joinPath(virtualPath, item.name),
|
|
||||||
}));
|
|
||||||
return items.sort((a, b) => {
|
|
||||||
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
|
|
||||||
if (a.name === '00 Project Files') return -1;
|
|
||||||
if (b.name === '00 Project Files') return 1;
|
|
||||||
return a.name.localeCompare(b.name);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function toListResponse(vPath, entries, { readOnly = false } = {}) {
|
|
||||||
return {
|
return {
|
||||||
configured: true,
|
ok: true,
|
||||||
path: vPath,
|
profile: { ...profile, clientCompanies },
|
||||||
canGoUp: vPath !== '/',
|
|
||||||
parentPath: parentDir(vPath),
|
|
||||||
entries,
|
|
||||||
readOnly,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function handler(req, res) {
|
function createAdminClient() {
|
||||||
try {
|
const supabaseUrl = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL;
|
||||||
const authHeader = req.headers.authorization || '';
|
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||||
if (!authHeader) return json(res, 401, { error: 'No authorization header' });
|
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 contentType = String(req.headers['content-type'] || '').split(';')[0].trim();
|
||||||
|
if (contentType === 'application/json') {
|
||||||
|
try {
|
||||||
|
return JSON.parse(rawBody.toString());
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
})();
|
||||||
const auth = await requirePortalUser(authHeader);
|
const auth = await requirePortalUser(authHeader);
|
||||||
if (!auth.ok) return json(res, auth.status, { error: auth.message });
|
if (!auth.ok) return json(res, auth.status, { error: auth.message });
|
||||||
|
|
||||||
const config = getConfig();
|
const admin = createAdminClient();
|
||||||
if (!config.configured || !config.token) {
|
if (action === 'ensure-company-folder') {
|
||||||
return json(res, 200, {
|
if (auth.profile.role !== 'team') {
|
||||||
configured: false,
|
return json(res, 403, { error: 'Forbidden' });
|
||||||
error: 'FileBrowser not configured.',
|
}
|
||||||
requiredEnv: ['FILEBROWSER_URL', 'FILEBROWSER_TOKEN'],
|
const companyId = String(body.companyId || '').trim();
|
||||||
|
if (!companyId) return json(res, 400, { error: 'companyId is required.' });
|
||||||
|
|
||||||
|
const { data: company, error: companyError } = await admin
|
||||||
|
.from('companies')
|
||||||
|
.select('id, name')
|
||||||
|
.eq('id', companyId)
|
||||||
|
.single();
|
||||||
|
if (companyError || !company) return json(res, 404, { error: 'Company not found.' });
|
||||||
|
|
||||||
|
const fullPath = joinPath(config.clientRoot, safeName(company.name, company.id));
|
||||||
|
await ensureDirectory(config, fullPath);
|
||||||
|
return json(res, 200, { ok: true, configured: true, path: fullPath });
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
});
|
});
|
||||||
}
|
if (!response.ok) {
|
||||||
|
const text = await response.text().catch(() => '');
|
||||||
const action = req.query.action || (req.method === 'GET' ? 'list' : '');
|
return json(res, response.status, { error: text || `Upload failed (${response.status})` });
|
||||||
const requestedPath = req.query.path || req.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 (auth.profile.role === 'client') {
|
return json(res, 200, { ok: true, configured: true, path: joinPath(surveyPath, fileName) });
|
||||||
return resolveClientPath(config, normalizePath(vPath), auth.profile.clientCompanies);
|
|
||||||
}
|
|
||||||
const root = resolveUserRoot(config, auth.profile);
|
|
||||||
return { virtual: false, fbPath: joinPath(root, normalizePath(vPath)) };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'GET' && action === 'config') {
|
await ensureDirectory(config, fullPath);
|
||||||
return json(res, 200, { configured: true, role: auth.profile.role, url: config.url });
|
for (const folderName of REQUEST_DEFAULT_SUBFOLDERS) {
|
||||||
|
await ensureDirectory(config, joinPath(fullPath, folderName));
|
||||||
}
|
}
|
||||||
|
return json(res, 200, { ok: true, configured: true, path: fullPath });
|
||||||
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 = 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 === 'POST' && action === 'upload-token') {
|
|
||||||
const resolved = toFbPath();
|
|
||||||
if (resolved.virtual) return json(res, 400, { error: 'Cannot upload to virtual directory' });
|
|
||||||
const token = getToken(config);
|
|
||||||
return json(res, 200, { token, url: config.url, fbPath: resolved.fbPath });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === 'POST' && action === 'mkdir') {
|
|
||||||
const folderName = safeName(req.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(req.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 = req.body?.srcPath;
|
|
||||||
const dstParentVPath = req.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 = req.body?.srcPath;
|
|
||||||
const dstPath = req.body?.dstPath;
|
|
||||||
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: 'move',
|
|
||||||
items: [{ fromSource: FB_SOURCE, fromPath: resolvedSrc.fbPath, toSource: FB_SOURCE, toPath: newFbPath }],
|
|
||||||
overwrite: false,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
return json(res, 200, { success: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
return json(res, 405, { error: 'Method not allowed' });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return json(res, error.status || 500, { error: error.message || 'Unexpected error' });
|
return json(res, error?.status || 500, { error: error?.message || 'Failed to ensure request folder.' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import {
|
||||||
|
PROJECT_DEFAULT_SUBFOLDERS,
|
||||||
|
REQUEST_DEFAULT_SUBFOLDERS,
|
||||||
|
createAdminClient,
|
||||||
|
createDirectory,
|
||||||
|
ensureDirectory,
|
||||||
|
getConfig,
|
||||||
|
joinPath,
|
||||||
|
listDirectories,
|
||||||
|
pooled,
|
||||||
|
safeName,
|
||||||
|
} from './_lib/filebrowser.js';
|
||||||
|
|
||||||
|
export const config = { maxDuration: 300 };
|
||||||
|
|
||||||
|
// Leave headroom under maxDuration so a partial run still returns its report.
|
||||||
|
const TIME_BUDGET_MS = 235000;
|
||||||
|
const CONCURRENCY = 8;
|
||||||
|
|
||||||
|
function json(res, status, body) {
|
||||||
|
res.status(status).setHeader('Content-Type', 'application/json');
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
|
res.send(JSON.stringify(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAuthorized(req) {
|
||||||
|
const expected = String(process.env.FOLDER_RECONCILE_SECRET || '').trim();
|
||||||
|
if (!expected) return false;
|
||||||
|
const header = req.headers['x-reconcile-secret'];
|
||||||
|
const provided = String(Array.isArray(header) ? header[0] : header || '').trim();
|
||||||
|
if (provided.length !== expected.length) return false;
|
||||||
|
let diff = 0;
|
||||||
|
for (let i = 0; i < expected.length; i += 1) diff |= expected.charCodeAt(i) ^ provided.charCodeAt(i);
|
||||||
|
return diff === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function handler(req, res) {
|
||||||
|
if (req.method !== 'POST') return json(res, 405, { error: 'Method not allowed' });
|
||||||
|
if (!isAuthorized(req)) return json(res, 401, { error: 'Unauthorized' });
|
||||||
|
|
||||||
|
const fbConfig = getConfig();
|
||||||
|
if (!fbConfig.configured) {
|
||||||
|
return json(res, 200, { ok: false, configured: false, warning: 'FileBrowser is not configured.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const outOfTime = () => Date.now() - startedAt > TIME_BUDGET_MS;
|
||||||
|
|
||||||
|
const created = [];
|
||||||
|
const orphans = [];
|
||||||
|
const errors = [];
|
||||||
|
const record = (type, path) => created.push({ type, path });
|
||||||
|
const fail = (scope, error) => errors.push({ scope, message: String(error?.message || error).slice(0, 300) });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const admin = createAdminClient();
|
||||||
|
const [companiesResult, projectsResult, tasksResult, subsResult] = await Promise.all([
|
||||||
|
admin.from('companies').select('id, name'),
|
||||||
|
admin.from('projects').select('id, name, company_id'),
|
||||||
|
admin.from('tasks').select('id, title, project_id'),
|
||||||
|
admin.from('profiles').select('id, name').eq('role', 'external'),
|
||||||
|
]);
|
||||||
|
for (const result of [companiesResult, projectsResult, tasksResult, subsResult]) {
|
||||||
|
if (result.error) throw result.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const companies = companiesResult.data || [];
|
||||||
|
const projects = projectsResult.data || [];
|
||||||
|
const tasks = tasksResult.data || [];
|
||||||
|
const subs = subsResult.data || [];
|
||||||
|
|
||||||
|
const rootDirs = await listDirectories(fbConfig, fbConfig.clientRoot);
|
||||||
|
if (rootDirs === null) {
|
||||||
|
await ensureDirectory(fbConfig, fbConfig.clientRoot);
|
||||||
|
record('client-root', fbConfig.clientRoot);
|
||||||
|
}
|
||||||
|
const rootSet = new Set(rootDirs || []);
|
||||||
|
|
||||||
|
let complete = true;
|
||||||
|
|
||||||
|
for (const company of companies) {
|
||||||
|
if (outOfTime()) { complete = false; break; }
|
||||||
|
|
||||||
|
const companyName = safeName(company.name, company.id);
|
||||||
|
const companyPath = joinPath(fbConfig.clientRoot, companyName);
|
||||||
|
const projectsPath = joinPath(companyPath, 'Projects');
|
||||||
|
const companyProjects = projects.filter(project => project.company_id === company.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!rootSet.has(companyName)) {
|
||||||
|
await ensureDirectory(fbConfig, companyPath);
|
||||||
|
record('company', companyPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
let projectDirs = await listDirectories(fbConfig, projectsPath);
|
||||||
|
if (projectDirs === null) {
|
||||||
|
if (companyProjects.length === 0) continue;
|
||||||
|
await ensureDirectory(fbConfig, projectsPath);
|
||||||
|
record('projects-root', projectsPath);
|
||||||
|
projectDirs = [];
|
||||||
|
}
|
||||||
|
const projectDirSet = new Set(projectDirs);
|
||||||
|
|
||||||
|
// Phase 1: create project folders that do not exist yet, with their defaults.
|
||||||
|
const missingProjects = companyProjects.filter(p => !projectDirSet.has(safeName(p.name, p.id)));
|
||||||
|
await pooled(missingProjects, CONCURRENCY, async (project) => {
|
||||||
|
const projectPath = joinPath(projectsPath, safeName(project.name, project.id));
|
||||||
|
try {
|
||||||
|
await createDirectory(fbConfig, projectPath);
|
||||||
|
record('project', projectPath);
|
||||||
|
for (const folderName of PROJECT_DEFAULT_SUBFOLDERS) {
|
||||||
|
await createDirectory(fbConfig, joinPath(projectPath, folderName));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
fail(projectPath, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Phase 2: list every project folder once to learn which task folders exist.
|
||||||
|
const existingProjects = companyProjects.filter(p => projectDirSet.has(safeName(p.name, p.id)));
|
||||||
|
const listings = await pooled(existingProjects, CONCURRENCY, async (project) => {
|
||||||
|
const projectPath = joinPath(projectsPath, safeName(project.name, project.id));
|
||||||
|
try {
|
||||||
|
return await listDirectories(fbConfig, projectPath);
|
||||||
|
} catch (error) {
|
||||||
|
fail(projectPath, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Phase 3: create the missing leaves, flattened so the pool stays saturated.
|
||||||
|
const work = [];
|
||||||
|
existingProjects.forEach((project, index) => {
|
||||||
|
const childDirs = listings[index];
|
||||||
|
if (childDirs === null) return;
|
||||||
|
const childSet = new Set(childDirs);
|
||||||
|
const projectPath = joinPath(projectsPath, safeName(project.name, project.id));
|
||||||
|
for (const folderName of PROJECT_DEFAULT_SUBFOLDERS) {
|
||||||
|
if (!childSet.has(folderName)) work.push({ type: 'project-subfolder', path: joinPath(projectPath, folderName), children: [] });
|
||||||
|
}
|
||||||
|
for (const task of tasks.filter(t => t.project_id === project.id)) {
|
||||||
|
const taskName = safeName(task.title, task.id);
|
||||||
|
if (childSet.has(taskName)) continue;
|
||||||
|
work.push({ type: 'task', path: joinPath(projectPath, taskName), children: REQUEST_DEFAULT_SUBFOLDERS });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
missingProjects.forEach((project) => {
|
||||||
|
const projectPath = joinPath(projectsPath, safeName(project.name, project.id));
|
||||||
|
for (const task of tasks.filter(t => t.project_id === project.id)) {
|
||||||
|
work.push({ type: 'task', path: joinPath(projectPath, safeName(task.title, task.id)), children: REQUEST_DEFAULT_SUBFOLDERS });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await pooled(work, CONCURRENCY, async (item) => {
|
||||||
|
if (outOfTime()) { complete = false; return; }
|
||||||
|
try {
|
||||||
|
await createDirectory(fbConfig, item.path);
|
||||||
|
record(item.type, item.path);
|
||||||
|
for (const folderName of item.children) {
|
||||||
|
await createDirectory(fbConfig, joinPath(item.path, folderName));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
fail(item.path, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reported only — never deleted, since these may hold real work.
|
||||||
|
const expected = new Set(companyProjects.map(p => safeName(p.name, p.id)));
|
||||||
|
for (const dirName of projectDirSet) {
|
||||||
|
if (!expected.has(dirName)) orphans.push(joinPath(projectsPath, dirName));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
fail(companyPath, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subs.length > 0 && !outOfTime()) {
|
||||||
|
try {
|
||||||
|
const subDirs = await listDirectories(fbConfig, fbConfig.subsRoot);
|
||||||
|
if (subDirs === null) {
|
||||||
|
await ensureDirectory(fbConfig, fbConfig.subsRoot);
|
||||||
|
record('subs-root', fbConfig.subsRoot);
|
||||||
|
}
|
||||||
|
const subDirSet = new Set(subDirs || []);
|
||||||
|
const missingSubs = subs.filter(profile => !subDirSet.has(safeName(profile.name, profile.id)));
|
||||||
|
await pooled(missingSubs, CONCURRENCY, async (profile) => {
|
||||||
|
const subPath = joinPath(fbConfig.subsRoot, safeName(profile.name, profile.id));
|
||||||
|
try {
|
||||||
|
await createDirectory(fbConfig, subPath);
|
||||||
|
record('subcontractor', subPath);
|
||||||
|
} catch (error) {
|
||||||
|
fail(subPath, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
fail(fbConfig.subsRoot, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return json(res, 200, {
|
||||||
|
ok: errors.length === 0,
|
||||||
|
complete,
|
||||||
|
elapsedMs: Date.now() - startedAt,
|
||||||
|
configured: true,
|
||||||
|
createdCount: created.length,
|
||||||
|
created,
|
||||||
|
orphanCount: orphans.length,
|
||||||
|
orphans,
|
||||||
|
errorCount: errors.length,
|
||||||
|
errors: errors.slice(0, 50),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return json(res, error?.status || 500, {
|
||||||
|
ok: false,
|
||||||
|
complete: false,
|
||||||
|
elapsedMs: Date.now() - startedAt,
|
||||||
|
error: String(error?.message || 'Folder reconcile failed.'),
|
||||||
|
createdCount: created.length,
|
||||||
|
created,
|
||||||
|
errorCount: errors.length,
|
||||||
|
errors: errors.slice(0, 50),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
const FB_SOURCE = 'files';
|
|
||||||
|
|
||||||
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 || '/fourgebranding/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 = 'files';
|
|
||||||
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 || '/fourgebranding/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 = 'files';
|
|
||||||
|
|
||||||
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 || '/fourgebranding/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,105 +0,0 @@
|
|||||||
const FB_SOURCE = 'files';
|
|
||||||
|
|
||||||
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 || '/fourgebranding/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'));
|
|
||||||
|
|
||||||
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,91 @@
|
|||||||
|
# File Sharing — How It Works
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
File sharing proxies all file operations through a Vercel serverless function (`/api/filebrowser`) to a self-hosted **FileBrowser Quantum** instance. The frontend never speaks directly to FBQ. Auth is validated server-side via Supabase on every request.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Infrastructure
|
||||||
|
|
||||||
|
| Component | Location |
|
||||||
|
|---|---|
|
||||||
|
| FBQ instance | `https://fourgebranding.krao.us` (internal: `192.168.2.200:8082`) |
|
||||||
|
| API proxy | `api/filebrowser.js` (Vercel function) |
|
||||||
|
| Frontend | `src/pages/FileSharing.jsx` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auth Flow
|
||||||
|
|
||||||
|
1. Frontend gets the current Supabase session access token (`supabase.auth.getSession()`).
|
||||||
|
2. Every request to `/api/filebrowser` sends the token as both `Authorization: Bearer <token>` header and `sb_access_token` query param.
|
||||||
|
3. The API function validates the token against Supabase and loads the caller's `profiles` row.
|
||||||
|
4. If valid, the function uses **its own server-side FBQ token** (`FILEBROWSER_TOKEN` env var) to talk to FBQ — the frontend never sees the FBQ token.
|
||||||
|
5. The FBQ token is a long-lived API token. If it expires or returns 401, the function falls back to admin username/password login (`FILEBROWSER_ADMIN_USER` / `FILEBROWSER_ADMIN_PASS`) to get a fresh token, cached in-memory for 30 minutes.
|
||||||
|
|
||||||
|
**No JWT auth from the frontend to FBQ.** That feature was removed — portal users authenticate via Supabase only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Role-Based Access Control
|
||||||
|
|
||||||
|
The API function maps each portal role to a different FBQ root path:
|
||||||
|
|
||||||
|
| Portal role | FBQ root | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `team` | `FILEBROWSER_TEAM_ROOT` (default `/fourgebranding`) | Full access, sees everything under team root |
|
||||||
|
| `client` | `FILEBROWSER_CLIENT_ROOT/{company-name}` (default `/fourgebranding/Clients/{name}`) | Scoped to their company folder; multi-company clients get a virtual root listing their companies |
|
||||||
|
| `external` | `FILEBROWSER_SUBS_ROOT/{their-name}` + assigned project folders | Personal folder + read access to `Projects/{project}` folders they are members of via `project_members` table |
|
||||||
|
|
||||||
|
Virtual directories (e.g. the multi-company root for clients, the `Projects/` node for externals) are synthesised by the API and never hit FBQ.
|
||||||
|
|
||||||
|
Path traversal (`..`) is blocked at `normalizePath()` — throws before any FBQ call.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Actions (`/api/filebrowser?action=...`)
|
||||||
|
|
||||||
|
| Action | Method | What it does |
|
||||||
|
|---|---|---|
|
||||||
|
| `list` | GET | List folder contents; returns `{ entries, path, canGoUp, ... }` |
|
||||||
|
| `download` | GET | Returns a signed FBQ download URL + token for direct browser download |
|
||||||
|
| `download-blob` | GET | Proxies the file bytes through Vercel (used for ZIP building) |
|
||||||
|
| `upload-token` | POST | Returns FBQ token + URL for direct upload from browser to FBQ |
|
||||||
|
| `mkdir` | POST | Creates a new folder |
|
||||||
|
| `delete` | DELETE | Deletes one file/folder |
|
||||||
|
| `rename` | POST | Renames a file/folder in place |
|
||||||
|
| `move` | POST | Moves or copies items; `mode: 'copy'` or `mode: 'move'` |
|
||||||
|
| `archive-move` | POST | Merge-moves a folder into a destination (merges contents if dest already exists) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend (`FileSharing.jsx`)
|
||||||
|
|
||||||
|
All FBQ calls go through the `fbqProxy()` helper which injects the current Supabase access token on every call.
|
||||||
|
|
||||||
|
- **Single file download**: `download` action → signed URL → hidden `<a>` click.
|
||||||
|
- **Multi-file / folder download**: `download-blob` streams each file, then JSZip builds a ZIP client-side.
|
||||||
|
- **Upload**: `upload-token` gets a short-lived FBQ token, then browser POSTs binary directly to `${fbqUrl}/api/resources?auth=${token}`.
|
||||||
|
- **Root path** per user is derived by `rootPath()` in `FileSharing.jsx` — team/external get `/`, clients with one company get `/Clients/{name}`, clients with multiple get `/Clients`.
|
||||||
|
- **Pinned folders** are persisted in `localStorage` keyed by `fbq_pins:{userId}`.
|
||||||
|
- **Nav tree** lazily loads folder children into a `navCache` ref; does not re-fetch already-loaded paths.
|
||||||
|
- **Drag-and-drop upload** uses `webkitGetAsEntry` to walk dropped folder trees, creates missing directories first, then uploads files.
|
||||||
|
- **Context menu** (right-click): Open, Download, Pin/Unpin, Copy, Cut, Paste here, Delete. Delete in context menu is restricted to `role === 'team'` (`accessScope.canManage`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Required Env Vars
|
||||||
|
|
||||||
|
```
|
||||||
|
FILEBROWSER_URL=https://fourgebranding.krao.us
|
||||||
|
FILEBROWSER_TOKEN=<long-lived FBQ API token>
|
||||||
|
FILEBROWSER_ADMIN_USER=admin
|
||||||
|
FILEBROWSER_ADMIN_PASS=<password>
|
||||||
|
FILEBROWSER_TEAM_ROOT=/fourgebranding
|
||||||
|
FILEBROWSER_CLIENT_ROOT=/fourgebranding/Clients
|
||||||
|
FILEBROWSER_SUBS_ROOT=/fourgebranding/team
|
||||||
|
FILEBROWSER_CLIENTS_ROOT=/fourgebranding/Clients
|
||||||
|
```
|
||||||
|
|
||||||
|
`FILEBROWSER_TOKEN` is the primary auth method. `ADMIN_USER`/`ADMIN_PASS` are only used for auto-refresh when the token expires.
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
# Site Completion and Billing Audit
|
||||||
|
|
||||||
|
Generated: 2026-06-04T13:58:27.846Z
|
||||||
|
|
||||||
|
- Listed sites: 95
|
||||||
|
- Found in database: 39
|
||||||
|
- Missing from database: 56
|
||||||
|
- Checked vs DB mismatches: 15
|
||||||
|
|
||||||
|
## Missing From Database
|
||||||
|
|
||||||
|
- 00278
|
||||||
|
- 00279
|
||||||
|
- 00281
|
||||||
|
- 00282
|
||||||
|
- 00284
|
||||||
|
- 00290
|
||||||
|
- 00291
|
||||||
|
- 30051
|
||||||
|
- 30054
|
||||||
|
- 30056
|
||||||
|
- 30058
|
||||||
|
- 30061
|
||||||
|
- 30063
|
||||||
|
- 30068
|
||||||
|
- 30095
|
||||||
|
- 30098
|
||||||
|
- 30210
|
||||||
|
- 30262
|
||||||
|
- 30319
|
||||||
|
- 30342
|
||||||
|
- 00288
|
||||||
|
- 30344
|
||||||
|
- 30345
|
||||||
|
- 30348
|
||||||
|
- 30349
|
||||||
|
- 30350
|
||||||
|
- 30355
|
||||||
|
- 30357
|
||||||
|
- 30407
|
||||||
|
- 68175
|
||||||
|
- 30412
|
||||||
|
- 30413
|
||||||
|
- 68215
|
||||||
|
- 68216
|
||||||
|
- 30437
|
||||||
|
- 68220
|
||||||
|
- 68432
|
||||||
|
- 68516
|
||||||
|
- 68617
|
||||||
|
- 68620
|
||||||
|
- 68631
|
||||||
|
- 68636
|
||||||
|
- 68637
|
||||||
|
- 68638
|
||||||
|
- 68715
|
||||||
|
- 68723
|
||||||
|
- 68751
|
||||||
|
- 68776
|
||||||
|
- 68777
|
||||||
|
- 68778
|
||||||
|
- 68779
|
||||||
|
- 68780
|
||||||
|
- 68781
|
||||||
|
- 68782
|
||||||
|
- 68784
|
||||||
|
- 68785
|
||||||
|
|
||||||
|
## Checked vs Database Mismatches
|
||||||
|
|
||||||
|
- 00286 00286 Riverside, CA: you marked completed, DB status is not_started
|
||||||
|
- 30055 30055 Moreno Valley, CA: you marked completed, DB status is client_review
|
||||||
|
- 30096 30096 Eerie, CO: you marked completed, DB status is not_started
|
||||||
|
- 68639 68639 NSA Los Lunas NM: you marked completed, DB status is client_review
|
||||||
|
- 68663 68663 NSA Oklahoma City OK: you marked completed, DB status is not_started
|
||||||
|
- 68664 68664 NSA Oklahoma City OK: you marked completed, DB status is not_started
|
||||||
|
- 68666 68666 NSA Oklahoma City, OK: you marked completed, DB status is not_started
|
||||||
|
- 68669 68669 NSA Oklahoma City OK: you marked completed, DB status is not_started
|
||||||
|
- 68670 68670 NSA Oklahoma City OK: you marked completed, DB status is not_started
|
||||||
|
- 68671 68671 NSA Oklahoma City OK: you marked completed, DB status is not_started
|
||||||
|
- 68673 68673 NSA Oklahoma City OK: you marked completed, DB status is client_review
|
||||||
|
- 68808 68808 Camas, WA: you marked completed, DB status is not_started
|
||||||
|
- 68809 68809 Centralia, WA: you marked completed, DB status is not_started
|
||||||
|
- 68810 68810 Chehalis, WA: you marked completed, DB status is not_started
|
||||||
|
- 68811 68811 Kelso, WA: you marked completed, DB status is not_started
|
||||||
|
|
||||||
|
## Audit Table
|
||||||
|
|
||||||
|
| Site # | DB Title | Internal # | In DB | Your Check | DB Completed | Match | Current R# | Active Status | R00 Billed | Billed Revisions | Completed Unbilled Revisions | Version Status Summary |
|
||||||
|
|---|---|---:|---:|---:|---:|---:|---|---|---|---|---|---|
|
||||||
|
| 00278 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 00279 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 00281 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 00282 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 00284 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 00285 | 00285 NSA Moreno Valley CA | 65 | Yes | No | No | Yes | R00 | not_started | No | — | — | R00: not_started |
|
||||||
|
| 00286 | 00286 Riverside, CA | 31 | Yes | Yes | No | No | R01 | not_started | Yes (INV-2026-006 (sent)) | — | — | R00: completed / billed; R01: not_started |
|
||||||
|
| 00290 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 00291 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 30049 | 30049 Elk Grove, CA | 32 | Yes | No | No | Yes | R00 | not_started | No | — | — | R00: not_started |
|
||||||
|
| 30051 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 30054 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 30055 | 30055 Moreno Valley, CA | 33 | Yes | Yes | No | No | R01 | client_review | Yes (INV-2026-006 (sent)) | — | — | R00: completed / billed; R01: client_review |
|
||||||
|
| 30056 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 30058 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 30060 | 30060 NSA Palmdale CA | 66 | Yes | No | No | Yes | R00 | not_started | No | — | — | R00: not_started |
|
||||||
|
| 30061 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 30062 | 30062 Riverside, CA | 34 | Yes | Yes | Yes | Yes | R00 | invoiced | Yes (INV-2026-006 (sent)) | — | — | R00: invoiced / billed |
|
||||||
|
| 30063 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 30068 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 30091 | 30091 NSA Colorado Springs CO | 67 | Yes | No | No | Yes | R00 | not_started | No | — | — | R00: not_started |
|
||||||
|
| 30094 | 30094 Colorado Springs, CO | 35 | Yes | Yes | Yes | Yes | R00 | client_approved | No | — | — | R00: client_approved |
|
||||||
|
| 30095 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 30096 | 30096 Eerie, CO | 36 | Yes | Yes | No | No | R02 | not_started | Yes (INV-2026-006 (sent)) | — | R01 | R00: completed / billed; R01: completed; R02: not_started |
|
||||||
|
| 30098 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 30101 | 30101 Monument, CO | 37 | Yes | No | No | Yes | R00 | on_hold | No | — | — | R00: on_hold |
|
||||||
|
| 30210 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 30211 | 30211 NSA Post Falls ID | 68 | Yes | No | No | Yes | R00 | not_started | No | — | — | R00: not_started |
|
||||||
|
| 30212 | 30212 NSA Sandpoint ID | 69 | Yes | No | No | Yes | R00 | not_started | No | — | — | R00: not_started |
|
||||||
|
| 30258 | 30258 Lebanon, NH | 38 | Yes | Yes | Yes | Yes | R00 | invoiced | Yes (INV-2026-006 (sent)) | — | — | R00: invoiced / billed |
|
||||||
|
| 30259 | 30259 Hamburg, NJ | 39 | Yes | Yes | Yes | Yes | R00 | invoiced | Yes (INV-2026-006 (sent)) | — | — | R00: invoiced / billed |
|
||||||
|
| 30261 | 30261 NSA Clovis NM | 70 | Yes | Yes | Yes | Yes | R00 | client_approved | No | — | — | R00: client_approved |
|
||||||
|
| 30262 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 30319 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 30342 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 00288 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 30344 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 30345 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 30348 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 30349 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 30350 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 30355 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 30357 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 30407 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 68175 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 30412 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 30413 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 68215 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 68216 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 30437 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 68220 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 68432 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 68452 | 68452 NSA Houston TX | 71 | Yes | No | No | Yes | R00 | not_started | No | — | — | R00: not_started |
|
||||||
|
| 68453 | 68453 NSA Katy TX | 72 | Yes | No | No | Yes | R00 | not_started | No | — | — | R00: not_started |
|
||||||
|
| 68454 | 68454 NSA Katy TX | 73 | Yes | No | No | Yes | R00 | not_started | No | — | — | R00: not_started |
|
||||||
|
| 68516 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 68617 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 68620 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 68631 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 68636 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 68637 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 68638 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 68639 | 68639 NSA Los Lunas NM | 40 | Yes | Yes | No | No | R01 | client_review | Yes (INV-2026-006 (sent)) | — | — | R00: completed / billed; R01: client_review |
|
||||||
|
| 68663 | 68663 NSA Oklahoma City OK | 41 | Yes | Yes | No | No | R01 | not_started | Yes (INV-2026-006 (sent)) | — | — | R00: completed / billed; R01: not_started |
|
||||||
|
| 68664 | 68664 NSA Oklahoma City OK | 42 | Yes | Yes | No | No | R01 | not_started | Yes (INV-2026-006 (sent)) | — | — | R00: completed / billed; R01: not_started |
|
||||||
|
| 68666 | 68666 NSA Oklahoma City, OK | 43 | Yes | Yes | No | No | R01 | not_started | Yes (INV-2026-006 (sent)) | — | — | R00: completed / billed; R01: not_started |
|
||||||
|
| 68669 | 68669 NSA Oklahoma City OK | 44 | Yes | Yes | No | No | R01 | not_started | Yes (INV-2026-006 (sent)) | — | — | R00: completed / billed; R01: not_started |
|
||||||
|
| 68670 | 68670 NSA Oklahoma City OK | 45 | Yes | Yes | No | No | R02 | not_started | Yes (INV-2026-006 (sent)) | R01 [INV-2026-006 (sent)] | — | R00: completed / billed; R01: completed / billed; R02: not_started |
|
||||||
|
| 68671 | 68671 NSA Oklahoma City OK | 46 | Yes | Yes | No | No | R01 | not_started | Yes (INV-2026-006 (sent)) | — | — | R00: completed / billed; R01: not_started |
|
||||||
|
| 68673 | 68673 NSA Oklahoma City OK | 47 | Yes | Yes | No | No | R01 | client_review | Yes (INV-2026-006 (sent)) | — | — | R00: completed / billed; R01: client_review |
|
||||||
|
| 68715 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 68720 | 68720 NSA Lancaster PA | 74 | Yes | Yes | Yes | Yes | R00 | client_approved | No | — | — | R00: client_approved |
|
||||||
|
| 68721 | 68721 NSA Mechanicsburg PA | 48 | Yes | Yes | Yes | Yes | R02 | client_approved | Yes (INV-2026-006 (sent)) | R01 [INV-2026-006 (sent)] | R02 | R00: completed / billed; R01: completed / billed; R02: client_approved |
|
||||||
|
| 68723 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 68724 | 68724 NSA York PA | 75 | Yes | Yes | Yes | Yes | R00 | invoiced | Yes (INV-2026-006 (sent)) | — | — | R00: invoiced / billed |
|
||||||
|
| 68751 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 68752 | 68752 NSA Brownsville TX | 76 | Yes | Yes | Yes | Yes | R00 | invoiced | Yes (INV-2026-006 (sent)) | — | — | R00: invoiced / billed |
|
||||||
|
| 68753 | 68753 NSA Brownsville TX | 77 | Yes | Yes | Yes | Yes | R00 | invoiced | Yes (INV-2026-006 (sent)) | — | — | R00: invoiced / billed |
|
||||||
|
| 68754 | 68754 NSA Brownsville TX | 78 | Yes | No | No | Yes | R00 | in_progress | No | — | — | R00: in_progress |
|
||||||
|
| 68755 | 68755 NSA Brownsville TX | 79 | Yes | No | No | Yes | R00 | not_started | No | — | — | R00: not_started |
|
||||||
|
| 68756 | 68756 NSA Brownsville TX | 80 | Yes | No | No | Yes | R00 | in_progress | No | — | — | R00: in_progress |
|
||||||
|
| 68758 | 68758 NSA Brownsville TX | 81 | Yes | Yes | Yes | Yes | R00 | invoiced | Yes (INV-2026-006 (sent)) | — | — | R00: invoiced / billed |
|
||||||
|
| 68776 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 68777 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 68778 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 68779 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 68780 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 68781 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 68782 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 68784 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 68785 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
|
||||||
|
| 68808 | 68808 Camas, WA | 49 | Yes | Yes | No | No | R01 | not_started | Yes (INV-2026-006 (sent)) | — | — | R00: completed / billed; R01: not_started |
|
||||||
|
| 68809 | 68809 Centralia, WA | 50 | Yes | Yes | No | No | R02 | not_started | Yes (INV-2026-006 (sent)) | R01 [INV-2026-006 (sent)] | — | R00: completed / billed; R01: completed / billed; R02: not_started |
|
||||||
|
| 68810 | 68810 Chehalis, WA | 51 | Yes | Yes | No | No | R02 | not_started | Yes (INV-2026-006 (sent)) | R01 [INV-2026-006 (sent)] | — | R00: completed / billed; R01: completed / billed; R02: not_started |
|
||||||
|
| 68811 | 68811 Kelso, WA | 52 | Yes | Yes | No | No | R02 | not_started | Yes (INV-2026-006 (sent)) | — | R01 | R00: completed / billed; R01: completed; R02: not_started |
|
||||||
@@ -2,6 +2,15 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const theme = localStorage.getItem('theme') || 'dark';
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
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/svg+xml" href="/favicon.svg" />
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png" />
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png" />
|
||||||
|
|||||||
@@ -1,171 +0,0 @@
|
|||||||
# Fourge Portal Layout System
|
|
||||||
|
|
||||||
This is the single source of truth for dashboard/profile visual structure and UI geometry.
|
|
||||||
|
|
||||||
## 1) Global Frame
|
|
||||||
- Viewport app shell: `height: 100vh`, `overflow: hidden`
|
|
||||||
- Main content gutter: `24px` all sides
|
|
||||||
- Sidebar: `width: 76px`, `top: 24px`, `left: 24px`, `height: calc(100vh - 48px)`, `border-radius: 8px`
|
|
||||||
- Main wrapper offset from sidebar: `margin-left: 100px`
|
|
||||||
- Page rhythm unit: `24px` (header spacing, card gaps, section gaps)
|
|
||||||
|
|
||||||
## 2) Theme + Background
|
|
||||||
- Background ownership is `body` via `background: var(--bg)`.
|
|
||||||
- Dark base token `--bg` is full gradient:
|
|
||||||
- radial glow + vertical dark gradient.
|
|
||||||
- Light base token `--bg` is full gradient:
|
|
||||||
- gray radial glow + white vertical gradient.
|
|
||||||
- Do not use `html` theme-gradient scripting for Safari chrome behavior.
|
|
||||||
|
|
||||||
## 3) Tokens
|
|
||||||
- Accent: `#F5A523`
|
|
||||||
- Card bg dark: `rgba(255,255,255,0.02)`
|
|
||||||
- Card bg light: `rgba(0,0,0,0.02)`
|
|
||||||
- Secondary card tone dark: `rgba(255,255,255,0.08)`
|
|
||||||
- Secondary card tone light: `rgba(0,0,0,0.08)`
|
|
||||||
- Border dark: `rgba(245,165,35,0.15)`
|
|
||||||
- Border light: `rgba(0,0,0,0.1)`
|
|
||||||
- Text primary dark/light: `#ffffff / #0d0d0d`
|
|
||||||
- Text secondary dark/light: `#a8a8a8 / rgba(0,0,0,0.6)`
|
|
||||||
- Text muted dark/light: `#666666 / rgba(0,0,0,0.38)`
|
|
||||||
|
|
||||||
## 4) Typography
|
|
||||||
- Font family: `Fourge`, then `-apple-system`, `BlinkMacSystemFont`, `'Segoe UI'`, `sans-serif`
|
|
||||||
- Base font size: `14px`
|
|
||||||
- Header title: `28px`, `500`, `line-height: 1.2`
|
|
||||||
- Header subtitle: `13px`
|
|
||||||
- Widget title: `11px`, `500`, uppercase, `letter-spacing: 0.8px`
|
|
||||||
- Body table text: `12px/13px` by column importance
|
|
||||||
|
|
||||||
## 5) Card System
|
|
||||||
- Default widget shell:
|
|
||||||
- `background: var(--card-bg)`
|
|
||||||
- `border: 1px solid var(--border)`
|
|
||||||
- `border-radius: 8px`
|
|
||||||
- `padding: 18px 21px`
|
|
||||||
- `backdrop-filter: blur(12px)` + `-webkit-backdrop-filter`
|
|
||||||
- Compact card radius (legacy generic `.card`): `4px` (do not use for new dashboard widgets)
|
|
||||||
|
|
||||||
## 6) Header + Top Right Controls
|
|
||||||
- Site header: `padding-top: 24px`, `padding-bottom: 24px`
|
|
||||||
- Right control row:
|
|
||||||
- Search icon button: `32x32`
|
|
||||||
- Search button to theme toggle space: `7px` (`search-wrap margin-right`)
|
|
||||||
- Theme toggle: `32x32`
|
|
||||||
- Theme toggle to avatar: `14px` (`avatar-wrap margin-left`)
|
|
||||||
- Avatar button: `49x49`, circle, `2px` inner ring + `2px` accent outline
|
|
||||||
|
|
||||||
## 7) Dashboard Grids (Team)
|
|
||||||
- Stat row: `grid-template-columns: 1fr 1fr 1fr 1.5fr`, `gap: 24`, `margin-bottom: 0`
|
|
||||||
- Row 2: `grid-template-columns: 1fr 280px`, `gap: 24`, `margin-top: 24`
|
|
||||||
- Row 3: `grid-template-columns: 1fr 1fr`, `gap: 24`, `margin-top: 24`
|
|
||||||
- Row 4 full-width: `margin-top: 24`
|
|
||||||
|
|
||||||
## 8) Stat Cards
|
|
||||||
- Card min height: `120px`
|
|
||||||
- Internal row gap: `21px`
|
|
||||||
- Label/value/sub spacing:
|
|
||||||
- Label: `margin-bottom: 5px`
|
|
||||||
- Value: `30px`, `400`, `letter-spacing: -0.5`, `line-height: 1.1`
|
|
||||||
- Sub: `12px`, `margin-top: 5px`
|
|
||||||
- Icon badge: `27x27`, circle
|
|
||||||
- Icon glyph: `13x13`
|
|
||||||
|
|
||||||
## 9) Calendar
|
|
||||||
- Card uses widget shell
|
|
||||||
- Header-to-grid gap: `14px`
|
|
||||||
- Weekday label: `10px`, `600`, `letter-spacing: 0.5`
|
|
||||||
- Day cell button: `28x28`, circular
|
|
||||||
- Day number: `12px`
|
|
||||||
- Today style: bg `#F5A523`, text `#0d0d0d`, `700`
|
|
||||||
- Dots: up to 3, each `3x3`, gap `2`
|
|
||||||
- Popover:
|
|
||||||
- Anchored left of cell: `right: calc(100% + 8px)`, vertical centered
|
|
||||||
- `width: 210px`, `padding: 10px 12px`, `border-radius: 8px`
|
|
||||||
- shadow `0 12px 32px rgba(0,0,0,0.45)`
|
|
||||||
- row dot `6x6`, row text `12px`
|
|
||||||
|
|
||||||
## 10) Activity + Performance Rows
|
|
||||||
- Visible rows target: 5
|
|
||||||
- Row layout: `display:flex`, `align-items:center`, `gap:10px`
|
|
||||||
- Row spacing: `margin-top: 10px` from second row onward
|
|
||||||
- Name text: `13px`
|
|
||||||
- Meta/date text: `11px`
|
|
||||||
- Progress track: `height: 4px`, `radius: 2px`
|
|
||||||
- Percentage width slot: `min-width: 28px`
|
|
||||||
|
|
||||||
## 11) Tables
|
|
||||||
- General table layout in dashboard cards: `table-layout: fixed`, `border-collapse: collapse`
|
|
||||||
- Header cells:
|
|
||||||
- `font-size: 10px`, `font-weight: 500`, uppercase, `letter-spacing: 0.6px`
|
|
||||||
- bottom spacing: `padding-bottom: 12px`
|
|
||||||
- Body cells:
|
|
||||||
- primary text: `13px`
|
|
||||||
- secondary/metrics text: `12px`
|
|
||||||
- row vertical spacing via cell padding: typically `5px`
|
|
||||||
- Hot Items 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.
|
|
||||||
- Exclude only non-data utility/action columns (checkbox-only, icon-only status marker, action buttons).
|
|
||||||
|
|
||||||
## 12) Profile Page
|
|
||||||
- Container: full available content width, column, `gap: 24`
|
|
||||||
- Top row: `grid-template-columns: 1fr 280px`, `gap: 24`
|
|
||||||
- At `<=1200px`: top row stacks to one column
|
|
||||||
- Main profile card uses widget shell
|
|
||||||
- Internal card layout:
|
|
||||||
- row `gap: 20px`
|
|
||||||
- portrait column `width: 160px`, portrait max `140x140`, circle
|
|
||||||
- detail grid `140px 1fr`, `row-gap: 8`, `column-gap: 12`, `margin-top: 14`
|
|
||||||
- social row `margin-top: 14`, `gap: 8`
|
|
||||||
- self-only edit button: `position: absolute`, `top: 18px`, `right: 21px` (aligns to card padding), `border-radius: 8px` (matches card), `height: 30px`, `font-size: 12px`
|
|
||||||
- Right calendar card shows only tasks/events assigned to the viewed profile user
|
|
||||||
- Modal:
|
|
||||||
- overlay: fixed inset, `z-index: 1200`, bg `rgba(0,0,0,0.58)`, blur `6px`
|
|
||||||
- overlay padding: `24px`
|
|
||||||
- modal width: `min(620px, 100%)`
|
|
||||||
- modal max-height: `calc(100vh - 48px)`, `overflow-y: auto`
|
|
||||||
|
|
||||||
## 13) Radius + Geometry Rules
|
|
||||||
- Dashboard/profile widgets: `8px` radius
|
|
||||||
- Sidebar: `8px` radius
|
|
||||||
- Buttons/input/dropdowns mostly `4px` radius
|
|
||||||
- Circular elements (avatar/day/icon badges): `50%`
|
|
||||||
|
|
||||||
## 14) Z-Index Stack
|
|
||||||
- Sidebar: `200`
|
|
||||||
- Header dropdowns/tooltips: `300`
|
|
||||||
- Calendar hover popover: `1002` within card context (`card can be 1001 active`)
|
|
||||||
- Modal overlay: `1200`
|
|
||||||
|
|
||||||
## 15) Motion
|
|
||||||
- Motion vars:
|
|
||||||
- fast `160ms`
|
|
||||||
- base `220ms`
|
|
||||||
- easing `cubic-bezier(0.22, 1, 0.36, 1)`
|
|
||||||
- Dropdown animation: `ui-fade-up` from `translateY(4px)` + opacity 0 -> 1
|
|
||||||
|
|
||||||
## 17) Hover Interaction Contract
|
|
||||||
- Sidebar, header icon buttons, dropdown items, and avatar menu items must show a visible hover surface before click.
|
|
||||||
- Single hover source-of-truth block controls these elements.
|
|
||||||
- Dark hover surface baseline: `#1f1f1f`.
|
|
||||||
- Light hover surface baseline: `rgba(0,0,0,0.08)`.
|
|
||||||
- Nav icon opacity must lift from muted to full on hover (`opacity: 1`).
|
|
||||||
- Hover and active must be visually distinct:
|
|
||||||
- hover uses stronger temporary contrast (`bg` + thin border),
|
|
||||||
- active remains persistent selected-state background.
|
|
||||||
|
|
||||||
## 16) Non-Negotiable Implementation Rules
|
|
||||||
- Keep gradient backgrounds on `html`, not `body`
|
|
||||||
- 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:
|
|
||||||
1. Open Tasks
|
|
||||||
2. Active Projects
|
|
||||||
3. Net Profit
|
|
||||||
4. Revenue (wide)
|
|
||||||
- Keep row 2 order: Recent Activity (left), Calendar (right)
|
|
||||||
@@ -10,7 +10,6 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/supabase-js": "^2.99.3",
|
"@supabase/supabase-js": "^2.99.3",
|
||||||
"heic-to": "^1.4.2",
|
"heic-to": "^1.4.2",
|
||||||
"heic2any": "^0.0.4",
|
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
"jspdf-autotable": "^5.0.7",
|
"jspdf-autotable": "^5.0.7",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
@@ -2026,12 +2025,6 @@
|
|||||||
"integrity": "sha512-y69thwxfNcEm2Vk8lbOD/cMabnvMJyOREfJYiCHcXCDqlfcPyJoBhyRc8+iDe1B95LRfpbTOpzxzY1xbRkdwBA==",
|
"integrity": "sha512-y69thwxfNcEm2Vk8lbOD/cMabnvMJyOREfJYiCHcXCDqlfcPyJoBhyRc8+iDe1B95LRfpbTOpzxzY1xbRkdwBA==",
|
||||||
"license": "LGPL-3.0"
|
"license": "LGPL-3.0"
|
||||||
},
|
},
|
||||||
"node_modules/heic2any": {
|
|
||||||
"version": "0.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/heic2any/-/heic2any-0.0.4.tgz",
|
|
||||||
"integrity": "sha512-3lLnZiDELfabVH87htnRolZ2iehX9zwpRyGNz22GKXIu0fznlblf0/ftppXKNqS26dqFSeqfIBhAmAj/uSp0cA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/hermes-estree": {
|
"node_modules/hermes-estree": {
|
||||||
"version": "0.25.1",
|
"version": "0.25.1",
|
||||||
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
|
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/supabase-js": "^2.99.3",
|
"@supabase/supabase-js": "^2.99.3",
|
||||||
"heic-to": "^1.4.2",
|
"heic-to": "^1.4.2",
|
||||||
"heic2any": "^0.0.4",
|
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
"jspdf-autotable": "^5.0.7",
|
"jspdf-autotable": "^5.0.7",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
@@ -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,165 +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 || '/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, 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 main() {
|
|
||||||
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(`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 companyDir = joinPath(CLIENT_ROOT, safeName(group.companyName));
|
|
||||||
const projectDir = joinPath(companyDir, 'Projects', safeName(group.projectName));
|
|
||||||
const taskDir = joinPath(projectDir, safeName(group.taskTitle));
|
|
||||||
const requestInfoDir = joinPath(taskDir, 'Request Info');
|
|
||||||
const revDir = joinPath(requestInfoDir, revFolder);
|
|
||||||
|
|
||||||
console.log(`\n[${group.companyName}] ${group.projectName} / ${group.taskTitle} / ${revFolder} (${group.files.length} files)`);
|
|
||||||
|
|
||||||
await mkdir(companyDir);
|
|
||||||
await mkdir(joinPath(companyDir, 'Projects'));
|
|
||||||
await mkdir(projectDir);
|
|
||||||
await mkdir(taskDir);
|
|
||||||
await mkdir(requestInfoDir);
|
|
||||||
await mkdir(revDir);
|
|
||||||
|
|
||||||
for (const file of group.files) {
|
|
||||||
try {
|
|
||||||
const { data: signed, error: signErr } = await admin.storage
|
|
||||||
.from('submissions')
|
|
||||||
.createSignedUrl(file.storage_path, 120);
|
|
||||||
|
|
||||||
if (signErr || !signed?.signedUrl) throw new Error(`signed url failed: ${signErr?.message}`);
|
|
||||||
|
|
||||||
const fileRes = await fetch(signed.signedUrl);
|
|
||||||
if (!fileRes.ok) throw new Error(`download failed: ${fileRes.status}`);
|
|
||||||
const fileBuffer = await fileRes.arrayBuffer();
|
|
||||||
|
|
||||||
const fbFilePath = joinPath(revDir, file.name);
|
|
||||||
await fbFetch('POST', '/api/resources', {
|
|
||||||
params: { path: fbFilePath, override: 'true' },
|
|
||||||
headers: { 'Content-Type': 'application/octet-stream' },
|
|
||||||
body: fileBuffer,
|
|
||||||
});
|
|
||||||
|
|
||||||
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); });
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { lazy, Suspense, Component } from 'react';
|
import { lazy, Suspense, Component } from 'react';
|
||||||
import { BrowserRouter, Routes, Route, Navigate, useParams } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route, Navigate, useParams } from 'react-router-dom';
|
||||||
import { AuthProvider, useAuth } from './context/AuthContext';
|
import { AuthProvider } from './context/AuthContext';
|
||||||
import ProtectedRoute from './components/ProtectedRoute';
|
import ProtectedRoute from './components/ProtectedRoute';
|
||||||
import PageLoader from './components/PageLoader';
|
import PageLoader from './components/PageLoader';
|
||||||
|
|
||||||
@@ -39,42 +39,52 @@ class ChunkErrorBoundary extends Component {
|
|||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
import PayInvoice from './pages/PayInvoice';
|
import PayInvoice from './pages/PayInvoice';
|
||||||
|
|
||||||
const ProfilePage = lazy(() => import('./pages/Settings'));
|
const ProfilePage = lazy(() => import('./pages/Profile'));
|
||||||
const CompaniesPage = lazy(() => import('./pages/CompaniesPage'));
|
const CompaniesPage = lazy(() => import('./pages/Companies'));
|
||||||
const CompanyDetail = lazy(() => import('./pages/CompanyDetail'));
|
const CompanyDetail = lazy(() => import('./pages/CompanyDetail'));
|
||||||
const Invoices = lazy(() => import('./pages/team/Invoices'));
|
const TeamInvoices = lazy(() => import('./pages/team/TeamInvoices'));
|
||||||
const RequestDetail = lazy(() => import('./pages/RequestDetail'));
|
const TaskDetail = lazy(() => import('./pages/TaskDetail'));
|
||||||
const CreateInvoice = lazy(() => import('./pages/team/CreateInvoice'));
|
const TeamCreateSubcontractorPO = lazy(() => import('./pages/team/TeamCreateSubcontractorPO'));
|
||||||
const CreateSubcontractorPO = lazy(() => import('./pages/team/CreateSubcontractorPO'));
|
const TeamInvoiceDetail = lazy(() => import('./pages/team/TeamInvoiceDetail'));
|
||||||
const InvoiceDetail = lazy(() => import('./pages/team/InvoiceDetail'));
|
const TeamSubcontractorPODetail = lazy(() => import('./pages/team/TeamSubcontractorPODetail'));
|
||||||
const SubcontractorPODetail = lazy(() => import('./pages/team/SubcontractorPODetail'));
|
const TeamSubInvoiceDetail = lazy(() => import('./pages/team/TeamSubInvoiceDetail'));
|
||||||
const SubInvoiceDetail = lazy(() => import('./pages/team/SubInvoiceDetail'));
|
|
||||||
const SurveyMaker = lazy(() => import('./pages/SurveyMaker'));
|
const SurveyMaker = lazy(() => import('./pages/SurveyMaker'));
|
||||||
const BrandBook = lazy(() => import('./pages/BrandBook'));
|
const BrandBook = lazy(() => import('./pages/BrandBook'));
|
||||||
const Converters = lazy(() => import('./pages/Converters'));
|
const Converters = lazy(() => import('./pages/Converters'));
|
||||||
const FileSharing = lazy(() => import('./pages/FileSharing'));
|
const TeamFourgePasswords = lazy(() => import('./pages/team/TeamFourgePasswords'));
|
||||||
const FourgePasswords = lazy(() => import('./pages/team/FourgePasswords'));
|
const TeamReports = lazy(() => import('./pages/team/TeamReports'));
|
||||||
const MyPurchaseOrders = lazy(() => import('./pages/external/MyPurchaseOrders'));
|
const ExternalMyPurchaseOrders = lazy(() => import('./pages/external/ExternalMyPurchaseOrders'));
|
||||||
const ExternalMyInvoices = lazy(() => import('./pages/external/MyInvoices'));
|
const ExternalMyInvoices = lazy(() => import('./pages/external/ExternalMyInvoices'));
|
||||||
const ExternalMyInvoiceDetail = lazy(() => import('./pages/external/MyInvoiceDetail'));
|
const ExternalMyInvoiceDetail = lazy(() => import('./pages/external/ExternalMyInvoiceDetail'));
|
||||||
const ExternalMyInvoiceCreate = lazy(() => import('./pages/external/MyInvoiceCreate'));
|
const ExternalMyInvoiceCreate = lazy(() => import('./pages/external/ExternalMyInvoiceCreate'));
|
||||||
const Projects = lazy(() => import('./pages/Projects'));
|
const ProjectDetailPage = lazy(() => import('./pages/ProjectDetail'));
|
||||||
const ProjectDetailPage = lazy(() => import('./pages/ProjectDetailPage'));
|
|
||||||
const DashboardPage = lazy(() => import('./pages/DashboardPage'));
|
|
||||||
const TeamDashboard = lazy(() => import('./pages/team/TeamDashboard'));
|
const TeamDashboard = lazy(() => import('./pages/team/TeamDashboard'));
|
||||||
const RequestsPage = lazy(() => import('./pages/RequestsPage'));
|
const RequestsPage = lazy(() => import('./pages/Tasks'));
|
||||||
const MyInvoices = lazy(() => import('./pages/client/MyInvoices'));
|
const ClientMyInvoices = lazy(() => import('./pages/client/ClientMyInvoices'));
|
||||||
const NewRequest = lazy(() => import('./pages/client/NewRequest'));
|
|
||||||
const NewProject = lazy(() => import('./pages/client/NewProject'));
|
|
||||||
|
|
||||||
function RedirectProjectDetail() {
|
function RedirectProjectDetail() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
return <Navigate to={`/projects/${id}`} replace />;
|
return <Navigate to={`/projects/${id}`} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function RedirectRequestDetail() {
|
function RedirectClientDetail() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
return <Navigate to={`/requests/${id}`} replace />;
|
return <Navigate to={`/projects/${id}`} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RedirectToTask() {
|
||||||
|
const { id } = useParams();
|
||||||
|
return <Navigate to={`/tasks/${id}`} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RedirectTeamInvoiceDetail() {
|
||||||
|
const { id } = useParams();
|
||||||
|
return <Navigate to={`/finances/${id}`} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RedirectSubsInvoiceDetail() {
|
||||||
|
const { id } = useParams();
|
||||||
|
return <Navigate to={`/subs-invoices/${id}`} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function NavigateCompanyDetail() {
|
function NavigateCompanyDetail() {
|
||||||
@@ -82,11 +92,7 @@ function NavigateCompanyDetail() {
|
|||||||
return <Navigate to={`/company/${id}`} replace />;
|
return <Navigate to={`/company/${id}`} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DashboardRoute() {
|
|
||||||
const { currentUser } = useAuth();
|
|
||||||
if (currentUser?.role === 'team') return <Navigate to="/team/dashboard" replace />;
|
|
||||||
return <DashboardPage />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -97,37 +103,42 @@ export default function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Login />} />
|
<Route path="/" element={<Login />} />
|
||||||
|
|
||||||
<Route path="/dashboard" element={<ProtectedRoute role={['external', 'client']}><DashboardRoute /></ProtectedRoute>} />
|
<Route path="/dashboard" element={<ProtectedRoute role={['team', 'external', 'client']}><TeamDashboard /></ProtectedRoute>} />
|
||||||
<Route path="/team/dashboard" element={<ProtectedRoute role={['team']}><TeamDashboard /></ProtectedRoute>} />
|
|
||||||
<Route path="/projects" element={<ProtectedRoute role={['team', 'external', 'client']}><Projects /></ProtectedRoute>} />
|
|
||||||
<Route path="/projects/:id" element={<ProtectedRoute role={['team', 'external', 'client']}><ProjectDetailPage /></ProtectedRoute>} />
|
<Route path="/projects/:id" element={<ProtectedRoute role={['team', 'external', 'client']}><ProjectDetailPage /></ProtectedRoute>} />
|
||||||
<Route path="/tasks/:id" element={<RedirectRequestDetail />} />
|
<Route path="/clients/:id" element={<RedirectClientDetail />} />
|
||||||
<Route path="/requests/:id" element={<ProtectedRoute role={['team', 'external', 'client']}><RequestDetail /></ProtectedRoute>} />
|
<Route path="/tasks/:id" element={<ProtectedRoute role={['team', 'external', 'client']}><TaskDetail /></ProtectedRoute>} />
|
||||||
<Route path="/company" element={<ProtectedRoute role={['team', 'client']}><CompaniesPage /></ProtectedRoute>} />
|
<Route path="/company" element={<ProtectedRoute role={['team', 'client']}><CompaniesPage /></ProtectedRoute>} />
|
||||||
<Route path="/company/:id" element={<ProtectedRoute role={['team', 'client']}><CompanyDetail /></ProtectedRoute>} />
|
<Route path="/company/:id" element={<ProtectedRoute role={['team', 'client']}><CompanyDetail /></ProtectedRoute>} />
|
||||||
<Route path="/companies" element={<Navigate to="/company" replace />} />
|
<Route path="/companies" element={<Navigate to="/company" replace />} />
|
||||||
<Route path="/companies/:id" element={<NavigateCompanyDetail />} />
|
<Route path="/companies/:id" element={<NavigateCompanyDetail />} />
|
||||||
<Route path="/requests" element={<ProtectedRoute role={['team', 'external', 'client']}><RequestsPage /></ProtectedRoute>} />
|
<Route path="/team/tasks" element={<Navigate to="/tasks" replace />} />
|
||||||
<Route path="/team-projects" element={<Navigate to="/projects" replace />} />
|
<Route path="/tasks" element={<ProtectedRoute role={['team', 'external', 'client']}><RequestsPage /></ProtectedRoute>} />
|
||||||
<Route path="/invoices" element={<ProtectedRoute role="team"><Invoices /></ProtectedRoute>} />
|
<Route path="/requests" element={<Navigate to="/tasks" replace />} />
|
||||||
<Route path="/invoices/new" element={<ProtectedRoute role="team"><CreateInvoice /></ProtectedRoute>} />
|
<Route path="/team-projects" element={<Navigate to="/tasks" replace />} />
|
||||||
<Route path="/subcontractor-pos/new" element={<ProtectedRoute role="team"><CreateSubcontractorPO /></ProtectedRoute>} />
|
<Route path="/finances" element={<ProtectedRoute role="team"><TeamInvoices /></ProtectedRoute>} />
|
||||||
<Route path="/invoices/:id" element={<ProtectedRoute role="team"><InvoiceDetail /></ProtectedRoute>} />
|
<Route path="/finances/new" element={<Navigate to="/finances" replace />} />
|
||||||
<Route path="/subcontractor-pos/:id" element={<ProtectedRoute role="team"><SubcontractorPODetail /></ProtectedRoute>} />
|
<Route path="/subcontractor-pos/new" element={<ProtectedRoute role="team"><TeamCreateSubcontractorPO /></ProtectedRoute>} />
|
||||||
<Route path="/sub-invoices/:id" element={<ProtectedRoute role="team"><SubInvoiceDetail /></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" replace />} />
|
||||||
|
<Route path="/invoices/:id" element={<RedirectTeamInvoiceDetail />} />
|
||||||
<Route path="/survey-maker" element={<ProtectedRoute role={['team', 'external']}><SurveyMaker /></ProtectedRoute>} />
|
<Route path="/survey-maker" element={<ProtectedRoute role={['team', 'external']}><SurveyMaker /></ProtectedRoute>} />
|
||||||
<Route path="/brand-book" element={<ProtectedRoute role={['team', 'external']}><BrandBook /></ProtectedRoute>} />
|
<Route path="/brand-book" element={<ProtectedRoute role={['team', 'external']}><BrandBook /></ProtectedRoute>} />
|
||||||
<Route path="/converters" element={<ProtectedRoute role={['team', 'external']}><Converters /></ProtectedRoute>} />
|
<Route path="/converters" element={<ProtectedRoute role={['team', 'external']}><Converters /></ProtectedRoute>} />
|
||||||
<Route path="/file-sharing" element={<ProtectedRoute role={['team', 'external', 'client']}><FileSharing /></ProtectedRoute>} />
|
<Route path="/fourge-passwords" element={<ProtectedRoute role="team"><TeamFourgePasswords /></ProtectedRoute>} />
|
||||||
<Route path="/file-uploads" element={<Navigate to="/file-sharing" replace />} />
|
<Route path="/reports" element={<ProtectedRoute role="team"><TeamReports /></ProtectedRoute>} />
|
||||||
<Route path="/fourge-passwords" element={<ProtectedRoute role="team"><FourgePasswords /></ProtectedRoute>} />
|
|
||||||
<Route path="/server-status" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/server-status" element={<Navigate to="/dashboard" replace />} />
|
||||||
<Route path="/assigned-requests" element={<Navigate to="/requests" replace />} />
|
<Route path="/assigned-requests" element={<Navigate to="/tasks" replace />} />
|
||||||
<Route path="/my-purchase-orders" element={<ProtectedRoute role="external"><MyPurchaseOrders /></ProtectedRoute>} />
|
<Route path="/my-purchase-orders" element={<ProtectedRoute role="external"><ExternalMyPurchaseOrders /></ProtectedRoute>} />
|
||||||
<Route path="/my-projects-sub" element={<Navigate to="/projects" replace />} />
|
<Route path="/my-projects-sub" element={<Navigate to="/tasks" replace />} />
|
||||||
<Route path="/my-invoices-sub" element={<ProtectedRoute role="external"><ExternalMyInvoices /></ProtectedRoute>} />
|
<Route path="/subs-invoices" element={<ProtectedRoute role="external"><ExternalMyInvoices /></ProtectedRoute>} />
|
||||||
<Route path="/my-invoices-sub/new" element={<ProtectedRoute role="external"><ExternalMyInvoiceCreate /></ProtectedRoute>} />
|
<Route path="/subs-invoices/new" element={<ProtectedRoute role="external"><ExternalMyInvoiceCreate /></ProtectedRoute>} />
|
||||||
<Route path="/my-invoices-sub/:id" element={<ProtectedRoute role="external"><ExternalMyInvoiceDetail /></ProtectedRoute>} />
|
<Route path="/subs-invoices/:id" element={<ProtectedRoute role="external"><ExternalMyInvoiceDetail /></ProtectedRoute>} />
|
||||||
|
<Route path="/my-invoices-sub" element={<Navigate to="/subs-invoices" replace />} />
|
||||||
|
<Route path="/my-invoices-sub/new" element={<Navigate to="/subs-invoices/new" replace />} />
|
||||||
|
<Route path="/my-invoices-sub/:id" element={<RedirectSubsInvoiceDetail />} />
|
||||||
|
|
||||||
<Route path="/profile" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} />
|
<Route path="/profile" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} />
|
||||||
<Route path="/profile/:id" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} />
|
<Route path="/profile/:id" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} />
|
||||||
@@ -135,13 +146,14 @@ export default function App() {
|
|||||||
|
|
||||||
<Route path="/my-dashboard" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/my-dashboard" element={<Navigate to="/dashboard" replace />} />
|
||||||
<Route path="/my-company" element={<Navigate to="/company" replace />} />
|
<Route path="/my-company" element={<Navigate to="/company" replace />} />
|
||||||
<Route path="/my-requests" element={<Navigate to="/requests" replace />} />
|
<Route path="/my-requests" element={<Navigate to="/tasks" replace />} />
|
||||||
<Route path="/my-requests/:id" element={<RedirectRequestDetail />} />
|
<Route path="/my-requests/:id" element={<RedirectToTask />} />
|
||||||
<Route path="/my-projects" element={<Navigate to="/projects" replace />} />
|
<Route path="/my-projects" element={<Navigate to="/tasks" replace />} />
|
||||||
<Route path="/my-projects/:id" element={<RedirectProjectDetail />} />
|
<Route path="/my-projects/:id" element={<RedirectProjectDetail />} />
|
||||||
<Route path="/my-invoices" element={<ProtectedRoute role="client"><MyInvoices /></ProtectedRoute>} />
|
<Route path="/client-invoices" element={<ProtectedRoute role="client"><ClientMyInvoices /></ProtectedRoute>} />
|
||||||
<Route path="/new-request" element={<ProtectedRoute role="client"><NewRequest /></ProtectedRoute>} />
|
<Route path="/my-invoices" element={<Navigate to="/client-invoices" replace />} />
|
||||||
<Route path="/new-project" element={<ProtectedRoute role="client"><NewProject /></ProtectedRoute>} />
|
<Route path="/new-request" element={<Navigate to="/tasks" replace />} />
|
||||||
|
<Route path="/new-project" element={<Navigate to="/tasks" replace />} />
|
||||||
|
|
||||||
<Route path="/pay/:id" element={<PayInvoice />} />
|
<Route path="/pay/:id" element={<PayInvoice />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 8.5 KiB |
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useRef } from 'react';
|
import { useId, useRef, useState } from 'react';
|
||||||
|
|
||||||
const MAX_FILES = 20;
|
const MAX_FILES = 20;
|
||||||
const MAX_SIZE_MB = 250;
|
const MAX_SIZE_MB = 250;
|
||||||
@@ -10,6 +10,7 @@ const formatSize = (bytes) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function FileAttachment({ files, onChange }) {
|
export default function FileAttachment({ files, onChange }) {
|
||||||
|
const inputId = useId();
|
||||||
const [errors, setErrors] = useState([]);
|
const [errors, setErrors] = useState([]);
|
||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
const dragCounter = useRef(0);
|
const dragCounter = useRef(0);
|
||||||
@@ -60,12 +61,12 @@ export default function FileAttachment({ files, onChange }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>
|
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 6 }}>
|
||||||
Attach Files
|
Attach Files
|
||||||
<span style={{ fontWeight: 400, color: 'var(--text-muted)', marginLeft: 6 }}>
|
<span style={{ fontWeight: 400, color: 'var(--text-muted)', marginLeft: 6, textTransform: 'none', letterSpacing: 0 }}>
|
||||||
Up to {MAX_FILES} files · Max {MAX_SIZE_MB} MB each · Any file type
|
Up to {MAX_FILES} files · Max {MAX_SIZE_MB} MB each · Any file type
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
onDragEnter={handleDragEnter}
|
onDragEnter={handleDragEnter}
|
||||||
@@ -74,15 +75,15 @@ export default function FileAttachment({ files, onChange }) {
|
|||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
style={{
|
style={{
|
||||||
border: `2px dashed ${dragging ? 'var(--accent)' : files.length > 0 ? 'var(--accent)' : 'var(--border)'}`,
|
border: `2px dashed ${dragging ? 'var(--accent)' : files.length > 0 ? 'var(--accent)' : 'var(--border)'}`,
|
||||||
borderRadius: 4, padding: '18px 16px', textAlign: 'center',
|
borderRadius: 8, padding: '16px', textAlign: 'center',
|
||||||
background: dragging ? 'color-mix(in srgb, var(--accent) 8%, var(--bg))' : 'var(--bg)',
|
background: dragging ? 'color-mix(in srgb, var(--accent) 8%, var(--bg))' : 'transparent',
|
||||||
transition: 'all 0.15s',
|
transition: 'all 160ms', cursor: 'pointer',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input type="file" multiple onChange={handleChange} style={{ display: 'none' }} id="req-file-upload" />
|
<input type="file" multiple onChange={handleChange} style={{ display: 'none' }} id={inputId} />
|
||||||
<label htmlFor="req-file-upload" style={{ cursor: 'pointer' }}>
|
<label htmlFor={inputId} style={{ cursor: 'pointer', textTransform: 'none', letterSpacing: 'normal', fontWeight: 400 }}>
|
||||||
<div style={{ fontSize: 22, marginBottom: 4 }}>{dragging ? '📂' : '📎'}</div>
|
<div style={{ fontSize: 20, marginBottom: 4 }}>{dragging ? '📂' : '📎'}</div>
|
||||||
<div style={{ fontWeight: 400, fontSize: 13, color: 'var(--text-primary)' }}>
|
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>
|
||||||
{dragging
|
{dragging
|
||||||
? 'Drop files here'
|
? 'Drop files here'
|
||||||
: files.length > 0
|
: files.length > 0
|
||||||
@@ -102,7 +103,7 @@ export default function FileAttachment({ files, onChange }) {
|
|||||||
{files.map((file, i) => (
|
{files.map((file, i) => (
|
||||||
<div key={i} style={{
|
<div key={i} style={{
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
padding: '7px 12px', background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)',
|
padding: '7px 12px', background: 'var(--card-bg)', borderRadius: 4, border: 'var(--card-border)',
|
||||||
}}>
|
}}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<span>📄</span>
|
<span>📄</span>
|
||||||
|
|||||||
@@ -1,664 +0,0 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
import LoadingButton from './LoadingButton';
|
|
||||||
import { supabase } from '../lib/supabase';
|
|
||||||
import { useAuth } from '../context/AuthContext';
|
|
||||||
|
|
||||||
function formatBytes(bytes) {
|
|
||||||
const value = Number(bytes || 0);
|
|
||||||
if (!value) return '—';
|
|
||||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
||||||
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 '—';
|
|
||||||
return new Date(dt).toLocaleDateString();
|
|
||||||
}
|
|
||||||
|
|
||||||
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','wma'].includes(e)) return { bg: '#db2777', color: '#fff' };
|
|
||||||
if (e === 'pdf') return { bg: '#dc2626', color: '#fff' };
|
|
||||||
if (['doc','docx','rtf','odt'].includes(e)) return { bg: '#2563eb', color: '#fff' };
|
|
||||||
if (['txt','md'].includes(e)) return { bg: '#64748b', color: '#fff' };
|
|
||||||
if (['xls','xlsx','csv','ods','numbers'].includes(e)) return { bg: '#16a34a', color: '#fff' };
|
|
||||||
if (['ppt','pptx','odp','key'].includes(e)) return { bg: '#ea580c', color: '#fff' };
|
|
||||||
if (['zip','rar','7z','tar','gz','bz2','xz'].includes(e)) return { bg: '#92400e', color: '#fff' };
|
|
||||||
if (['js','ts','jsx','tsx','html','css','scss','json','py','rb','php','java','c','cpp','cs','go','rs'].includes(e)) return { bg: '#0891b2', color: '#fff' };
|
|
||||||
if (['ttf','otf','woff','woff2'].includes(e)) return { bg: '#6b7280', 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','xd'].includes(e)) return { bg: '#7c3aed', color: '#fff' };
|
|
||||||
return { bg: '#475569', color: '#fff' };
|
|
||||||
}
|
|
||||||
|
|
||||||
function FileIcon({ entry }) {
|
|
||||||
if (entry.type === 'dir') return <span className="file-icon">📁</span>;
|
|
||||||
const ext = (entry.name.includes('.') ? entry.name.split('.').pop() : '').toUpperCase().slice(0, 4) || 'FILE';
|
|
||||||
const { bg, color } = fileIconStyle(ext);
|
|
||||||
return (
|
|
||||||
<span className="file-icon" style={{ background: bg, color, border: 'none', fontSize: 9, fontWeight: 400, letterSpacing: 0.4, fontFamily: 'monospace' }}>
|
|
||||||
{ext}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function pathParts(path) {
|
|
||||||
return String(path || '/').split('/').filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
function pathTo(index, parts) {
|
|
||||||
return `/${parts.slice(0, index + 1).join('/')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function encodeFbPath(path) {
|
|
||||||
return path.split('/').map(p => encodeURIComponent(p)).join('/');
|
|
||||||
}
|
|
||||||
|
|
||||||
function joinVirtualPath(...parts) {
|
|
||||||
return ('/' + parts.join('/')).replace(/\/+/g, '/').replace(/\/$/, '') || '/';
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function FileBrowser({ initialPath = '/', rootPath = '/', showSync = false }) {
|
|
||||||
const { currentUser } = useAuth();
|
|
||||||
const [currentPath, setCurrentPath] = useState(initialPath);
|
|
||||||
const [entries, setEntries] = useState([]);
|
|
||||||
const [configured, setConfigured] = useState(true);
|
|
||||||
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 [movingEntry, setMovingEntry] = useState(null);
|
|
||||||
const [renamingEntry, setRenamingEntry] = useState(null);
|
|
||||||
const [renameValue, setRenameValue] = useState('');
|
|
||||||
const [draggedEntry, setDraggedEntry] = useState(null);
|
|
||||||
const [dragOverFolder, setDragOverFolder] = useState(null);
|
|
||||||
const [uploadProgress, setUploadProgress] = useState(null);
|
|
||||||
const fileInputRef = useRef(null);
|
|
||||||
const folderInputRef = useRef(null);
|
|
||||||
|
|
||||||
const breadcrumbs = useMemo(() => pathParts(currentPath), [currentPath]);
|
|
||||||
|
|
||||||
const apiFetch = async (url, options = {}) => {
|
|
||||||
const { data: { session } } = await supabase.auth.getSession();
|
|
||||||
if (!session?.access_token) throw new Error('Your 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('');
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams({ action: 'list', path });
|
|
||||||
const data = await apiFetch(`/api/filebrowser?${params}`);
|
|
||||||
setConfigured(data.configured !== false);
|
|
||||||
setEntries(data.entries || []);
|
|
||||||
setCurrentPath(data.path || '/');
|
|
||||||
setParentPath(data.parentPath || '/');
|
|
||||||
setCanGoUp(data.canGoUp || false);
|
|
||||||
setReadOnly(data.readOnly || false);
|
|
||||||
if (data.configured === false) setError(data.error || 'FileBrowser is not configured.');
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadFiles(initialPath);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [initialPath]);
|
|
||||||
|
|
||||||
const openFolder = (entry) => {
|
|
||||||
if (entry.type === 'dir') loadFiles(entry.path);
|
|
||||||
};
|
|
||||||
|
|
||||||
const downloadFile = async (entry) => {
|
|
||||||
setWorking(`download:${entry.path}`);
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
const data = await apiFetch(`/api/filebrowser?action=download&path=${encodeURIComponent(entry.path)}`);
|
|
||||||
if (data.url && data.token) {
|
|
||||||
// Append token as query param for browser direct download
|
|
||||||
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 deleteEntry = async (entry) => {
|
|
||||||
const kind = entry.type === 'dir' ? 'folder' : 'file';
|
|
||||||
if (!window.confirm(`Delete "${entry.name}" ${kind}? This cannot be undone.`)) return;
|
|
||||||
|
|
||||||
setWorking(`delete:${entry.path}`);
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
await apiFetch(`/api/filebrowser?action=delete&path=${encodeURIComponent(entry.path)}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
});
|
|
||||||
await loadFiles(currentPath);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
} finally {
|
|
||||||
setWorking('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const createFolder = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!folderName.trim()) return;
|
|
||||||
|
|
||||||
setWorking('mkdir');
|
|
||||||
setError('');
|
|
||||||
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(`rename:${renamingEntry.path}`);
|
|
||||||
setError('');
|
|
||||||
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 startRename = (entry) => {
|
|
||||||
setMovingEntry(null);
|
|
||||||
setRenamingEntry(entry);
|
|
||||||
setRenameValue(entry.name);
|
|
||||||
};
|
|
||||||
|
|
||||||
async function uploadOneFile(url, token, fbPath, file, retries = 3) {
|
|
||||||
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${url}/api/resources?source=files&path=${encodeURIComponent(fbPath)}&override=true`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/octet-stream',
|
|
||||||
},
|
|
||||||
body: file,
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text();
|
|
||||||
throw new Error(`${text || res.status}`);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
} catch (e) {
|
|
||||||
if (attempt === retries) throw new Error(`Upload failed for ${file.name} after ${retries} attempts: ${e.message}`);
|
|
||||||
await new Promise(r => setTimeout(r, attempt * 1000));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runConcurrent(tasks, concurrency = 2) {
|
|
||||||
let index = 0;
|
|
||||||
let done = 0;
|
|
||||||
const total = tasks.length;
|
|
||||||
const errors = [];
|
|
||||||
await Promise.all(Array.from({ length: concurrency }, async () => {
|
|
||||||
while (index < total) {
|
|
||||||
const task = tasks[index++];
|
|
||||||
try { await task(); } catch (e) { errors.push(e); }
|
|
||||||
done++;
|
|
||||||
setUploadProgress(Math.round((done / total) * 100));
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
if (errors.length) throw errors[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upload files directly to FileBrowser using admin token
|
|
||||||
const uploadFiles = async (files) => {
|
|
||||||
const selected = Array.from(files || []);
|
|
||||||
if (!selected.length) return;
|
|
||||||
|
|
||||||
setWorking('upload');
|
|
||||||
setUploadProgress(0);
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
const tokenData = await apiFetch('/api/filebrowser?action=upload-token', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ path: currentPath }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { token, url, fbPath } = tokenData;
|
|
||||||
const tasks = selected.map(file => () => uploadOneFile(url, token, joinVirtualPath(fbPath, file.name), file));
|
|
||||||
await runConcurrent(tasks);
|
|
||||||
setUploadProgress(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 selected = Array.from(files || []).filter(f => f.webkitRelativePath);
|
|
||||||
if (!selected.length) return;
|
|
||||||
|
|
||||||
setWorking('upload');
|
|
||||||
setUploadProgress(0);
|
|
||||||
setError('');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const tokenData = await apiFetch('/api/filebrowser?action=upload-token', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ path: currentPath }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { token, url, fbPath } = tokenData;
|
|
||||||
|
|
||||||
// Create directories sequentially shallow-first
|
|
||||||
const dirsNeeded = new Set();
|
|
||||||
for (const file of selected) {
|
|
||||||
const parts = file.webkitRelativePath.split('/').slice(0, -1);
|
|
||||||
for (let i = 1; i <= parts.length; i++) dirsNeeded.add(parts.slice(0, i).join('/'));
|
|
||||||
}
|
|
||||||
const sortedDirs = [...dirsNeeded].sort((a, b) => a.split('/').length - b.split('/').length);
|
|
||||||
for (const dir of sortedDirs) {
|
|
||||||
const dirFbPath = joinVirtualPath(fbPath, dir);
|
|
||||||
await fetch(`${url}/api/resources?source=files&path=${encodeURIComponent(dirFbPath)}&isDir=true`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upload files concurrently
|
|
||||||
const tasks = selected.map(file => () => uploadOneFile(url, token, joinVirtualPath(fbPath, file.webkitRelativePath), file));
|
|
||||||
await runConcurrent(tasks);
|
|
||||||
setUploadProgress(100);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
} finally {
|
|
||||||
setWorking('');
|
|
||||||
setUploadProgress(null);
|
|
||||||
if (folderInputRef.current) folderInputRef.current.value = '';
|
|
||||||
await loadFiles(currentPath);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const moveEntry = async (entry, targetFolderPath) => {
|
|
||||||
setWorking(`move:${entry.path}`);
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
await apiFetch('/api/filebrowser?action=move', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ srcPath: entry.path, dstPath: targetFolderPath }),
|
|
||||||
});
|
|
||||||
setMovingEntry(null);
|
|
||||||
await loadFiles(currentPath);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
} finally {
|
|
||||||
setWorking('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragEnter = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!configured || loading || working || draggedEntry || readOnly) return;
|
|
||||||
setDragging(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragOver = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!configured || loading || working || draggedEntry || readOnly) return;
|
|
||||||
e.dataTransfer.dropEffect = 'copy';
|
|
||||||
setDragging(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragLeave = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!e.currentTarget.contains(e.relatedTarget)) setDragging(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const readFsEntry = (entry) => new Promise((resolve) => {
|
|
||||||
if (entry.isFile) {
|
|
||||||
entry.file(file => {
|
|
||||||
const rel = entry.fullPath.replace(/^\//, '');
|
|
||||||
Object.defineProperty(file, 'webkitRelativePath', { value: rel, 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([]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleDrop = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setDragging(false);
|
|
||||||
if (draggedEntry) return;
|
|
||||||
if (!configured || loading || working) return;
|
|
||||||
|
|
||||||
const items = Array.from(e.dataTransfer.items || []);
|
|
||||||
const fsEntries = items.map(item => item.webkitGetAsEntry?.()).filter(Boolean);
|
|
||||||
|
|
||||||
if (fsEntries.length && fsEntries.some(en => en.isDirectory)) {
|
|
||||||
const allFiles = (await Promise.all(fsEntries.map(readFsEntry))).flat();
|
|
||||||
uploadFolder(allFiles);
|
|
||||||
} else {
|
|
||||||
if (!e.dataTransfer.files?.length) return;
|
|
||||||
uploadFiles(e.dataTransfer.files);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRowDragStart = (e, entry) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setDraggedEntry(entry);
|
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRowDragEnd = () => {
|
|
||||||
setDraggedEntry(null);
|
|
||||||
setDragOverFolder(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFolderDragOver = (e, folder) => {
|
|
||||||
if (!draggedEntry || draggedEntry.path === folder.path) return;
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
e.dataTransfer.dropEffect = 'move';
|
|
||||||
setDragOverFolder(folder.path);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFolderDragLeave = (e) => {
|
|
||||||
if (!e.currentTarget.contains(e.relatedTarget)) setDragOverFolder(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFolderDrop = (e, folder) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setDragOverFolder(null);
|
|
||||||
if (!draggedEntry || draggedEntry.path === folder.path) return;
|
|
||||||
const entry = draggedEntry;
|
|
||||||
setDraggedEntry(null);
|
|
||||||
moveEntry(entry, folder.path);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
className={`file-browser${dragging ? ' file-browser-dragging' : ''}`}
|
|
||||||
onDragEnter={handleDragEnter}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
>
|
|
||||||
{(loading || working || uploadProgress !== null) && (
|
|
||||||
<div className="file-browser-progress">
|
|
||||||
<div
|
|
||||||
className={`file-browser-progress-bar${uploadProgress === null ? ' indeterminate' : ''}`}
|
|
||||||
style={uploadProgress !== null ? { width: `${uploadProgress}%` } : undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{dragging && (
|
|
||||||
<div className="file-drop-overlay">
|
|
||||||
<div className="file-drop-panel">
|
|
||||||
<div className="file-drop-icon">↑</div>
|
|
||||||
<div className="file-drop-title">Drop files to upload</div>
|
|
||||||
<div className="file-drop-subtitle">Files will be added to the current folder.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="file-browser-toolbar">
|
|
||||||
<div className="file-browser-breadcrumbs">
|
|
||||||
<button type="button" onClick={() => loadFiles(rootPath)} className="file-breadcrumb">Files</button>
|
|
||||||
{breadcrumbs.slice(pathParts(rootPath).length).map((part, index) => {
|
|
||||||
const absIndex = pathParts(rootPath).length + index;
|
|
||||||
return (
|
|
||||||
<button type="button" key={`${part}-${absIndex}`} onClick={() => loadFiles(pathTo(absIndex, breadcrumbs))} className="file-breadcrumb">
|
|
||||||
{part}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="file-browser-actions">
|
|
||||||
{!readOnly && (showFolderInput ? (
|
|
||||||
<form style={{ display: 'flex', gap: 6 }} onSubmit={createFolder}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={folderName}
|
|
||||||
onChange={(e) => setFolderName(e.target.value)}
|
|
||||||
placeholder="Folder name"
|
|
||||||
autoFocus
|
|
||||||
disabled={!configured || loading || Boolean(working)}
|
|
||||||
style={{ fontSize: 13, padding: '4px 8px', borderRadius: 4, border: '1px solid var(--border)', background: 'var(--input-bg)', color: 'var(--text-primary)', width: 160 }}
|
|
||||||
/>
|
|
||||||
<LoadingButton type="submit" className="btn btn-outline btn-sm" loading={working === 'mkdir'} disabled={!folderName.trim() || !configured || loading || Boolean(working)} loadingText="Creating...">
|
|
||||||
Create
|
|
||||||
</LoadingButton>
|
|
||||||
<button type="button" className="btn btn-outline btn-sm" onClick={() => { setShowFolderInput(false); setFolderName(''); }}>Cancel</button>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<button className="btn btn-outline btn-sm" disabled={!configured || loading || Boolean(working)} onClick={() => setShowFolderInput(true)}>
|
|
||||||
+ New Folder
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<LoadingButton className="btn btn-outline btn-sm" loading={loading} disabled={Boolean(working)} loadingText="Refreshing..." onClick={() => loadFiles(currentPath)}>
|
|
||||||
⟳ Refresh
|
|
||||||
</LoadingButton>
|
|
||||||
{entries.length > 0 && (
|
|
||||||
<LoadingButton className="btn btn-outline btn-sm" loading={working === `download:${currentPath}`} disabled={Boolean(working)} loadingText="Zipping..." onClick={() => downloadFile({ path: currentPath, name: breadcrumbs[breadcrumbs.length - 1] || 'files', type: 'dir' })}>
|
|
||||||
↓ ZIP
|
|
||||||
</LoadingButton>
|
|
||||||
)}
|
|
||||||
{!readOnly && (
|
|
||||||
<>
|
|
||||||
<input ref={fileInputRef} type="file" multiple className="file-upload-input" onChange={(e) => uploadFiles(e.target.files)} />
|
|
||||||
<input ref={folderInputRef} type="file" className="file-upload-input" onChange={(e) => uploadFolder(e.target.files)} {...{ webkitdirectory: '' }} />
|
|
||||||
<LoadingButton className="btn btn-outline btn-sm" loading={working === 'upload'} disabled={!configured || loading || Boolean(working)} loadingText="Uploading..." onClick={() => folderInputRef.current?.click()}>
|
|
||||||
↑ Folder
|
|
||||||
</LoadingButton>
|
|
||||||
<LoadingButton className="btn btn-primary btn-sm" loading={working === 'upload'} disabled={!configured || loading || Boolean(working)} loadingText="Uploading..." onClick={() => fileInputRef.current?.click()}>
|
|
||||||
↑ Files
|
|
||||||
</LoadingButton>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{uploadProgress !== null && (
|
|
||||||
<div style={{ padding: '4px 12px', fontSize: 12, color: 'var(--text-muted)' }}>
|
|
||||||
Uploading... {uploadProgress}%
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && <div className="notification notification-info">{error}</div>}
|
|
||||||
|
|
||||||
{draggedEntry && (
|
|
||||||
<div style={{ padding: '6px 12px', fontSize: 12, color: 'var(--text-muted)' }}>
|
|
||||||
Dragging "{draggedEntry.name}" — drop onto a folder to move it
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="file-list">
|
|
||||||
{canGoUp && currentPath !== rootPath && (
|
|
||||||
<button type="button" className="file-row file-row-button" onClick={() => loadFiles(parentPath)} disabled={loading || Boolean(working)}>
|
|
||||||
<span className="file-icon">↰</span>
|
|
||||||
<span className="file-name">Up one folder</span>
|
|
||||||
<span className="file-meta">—</span>
|
|
||||||
<span className="file-meta">—</span>
|
|
||||||
<span />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="file-row file-row-head">
|
|
||||||
<span />
|
|
||||||
<span>Name</span>
|
|
||||||
<span style={{ textAlign: 'right' }}>Size</span>
|
|
||||||
<span style={{ textAlign: 'right' }}>Modified</span>
|
|
||||||
<span />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="empty-state">Loading files...</div>
|
|
||||||
) : entries.length === 0 ? (
|
|
||||||
<div className="empty-state">
|
|
||||||
<h3>No files here yet</h3>
|
|
||||||
<p>Upload files or create a folder to start this workspace.</p>
|
|
||||||
</div>
|
|
||||||
) : entries.map(entry => {
|
|
||||||
const isMoving = movingEntry?.path === entry.path;
|
|
||||||
const isRenaming = renamingEntry?.path === entry.path;
|
|
||||||
const targetFolders = entries.filter(e => e.type === 'dir' && e.path !== entry.path);
|
|
||||||
const isDragTarget = entry.type === 'dir' && draggedEntry && draggedEntry.path !== entry.path;
|
|
||||||
const isDragOver = dragOverFolder === entry.path;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`file-row${isDragOver ? ' file-row-drag-over' : ''}`}
|
|
||||||
key={`${entry.type}:${entry.path}`}
|
|
||||||
draggable={!working}
|
|
||||||
onDragStart={(e) => handleRowDragStart(e, entry)}
|
|
||||||
onDragEnd={handleRowDragEnd}
|
|
||||||
onDragOver={isDragTarget ? (e) => handleFolderDragOver(e, entry) : undefined}
|
|
||||||
onDragLeave={isDragTarget ? handleFolderDragLeave : undefined}
|
|
||||||
onDrop={isDragTarget ? (e) => handleFolderDrop(e, entry) : undefined}
|
|
||||||
style={isDragOver ? { outline: '2px solid var(--accent)', borderRadius: 4 } : undefined}
|
|
||||||
>
|
|
||||||
<FileIcon entry={entry} />
|
|
||||||
{isRenaming ? (
|
|
||||||
<form style={{ display: 'flex', gap: 6, flex: 1 }} onSubmit={renameEntry}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={renameValue}
|
|
||||||
onChange={(e) => setRenameValue(e.target.value)}
|
|
||||||
autoFocus
|
|
||||||
disabled={Boolean(working)}
|
|
||||||
style={{ fontSize: 13, padding: '2px 8px', borderRadius: 4, border: '1px solid var(--border)', background: 'var(--input-bg)', color: 'var(--text-primary)', flex: 1, minWidth: 0 }}
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Escape') setRenamingEntry(null); }}
|
|
||||||
/>
|
|
||||||
<LoadingButton type="submit" className="btn btn-outline btn-sm" loading={working === `rename:${entry.path}`} disabled={!renameValue.trim() || Boolean(working)} loadingText="Renaming...">
|
|
||||||
Save
|
|
||||||
</LoadingButton>
|
|
||||||
<button type="button" className="btn btn-outline btn-sm" onClick={() => setRenamingEntry(null)}>Cancel</button>
|
|
||||||
</form>
|
|
||||||
) : entry.type === 'dir' ? (
|
|
||||||
<button type="button" className="file-name file-name-button" onClick={() => openFolder(entry)} disabled={Boolean(working)}>
|
|
||||||
{entry.name}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span className="file-name">{entry.name}</span>
|
|
||||||
)}
|
|
||||||
{!isRenaming && (
|
|
||||||
<>
|
|
||||||
<span className="file-meta">{formatBytes(entry.size)}</span>
|
|
||||||
<span className="file-meta">{formatDate(entry.mtime)}</span>
|
|
||||||
<span className="file-row-actions">
|
|
||||||
{readOnly ? null : isMoving ? (
|
|
||||||
<>
|
|
||||||
<span style={{ fontSize: 11, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>Move to:</span>
|
|
||||||
{targetFolders.length === 0 ? (
|
|
||||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>No folders here</span>
|
|
||||||
) : targetFolders.map(folder => (
|
|
||||||
<LoadingButton
|
|
||||||
key={folder.path}
|
|
||||||
className="btn btn-outline btn-sm"
|
|
||||||
loading={working === `move:${entry.path}`}
|
|
||||||
disabled={Boolean(working)}
|
|
||||||
loadingText="Moving..."
|
|
||||||
onClick={() => moveEntry(entry, folder.path)}
|
|
||||||
>
|
|
||||||
{folder.name}
|
|
||||||
</LoadingButton>
|
|
||||||
))}
|
|
||||||
<button type="button" className="btn btn-outline btn-sm" onClick={() => setMovingEntry(null)} disabled={Boolean(working)}>✕</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<LoadingButton className="btn-icon" title={entry.type === 'dir' ? 'Download ZIP' : 'Download'} loading={working === `download:${entry.path}`} disabled={Boolean(working)} loadingText="↓" onClick={() => downloadFile(entry)}>
|
|
||||||
↓
|
|
||||||
</LoadingButton>
|
|
||||||
<button type="button" className="btn-icon" title="Rename" disabled={Boolean(working)} onClick={() => startRename(entry)}>
|
|
||||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
|
||||||
</button>
|
|
||||||
<LoadingButton className="btn-icon btn-icon-danger" title="Delete" loading={working === `delete:${entry.path}`} disabled={Boolean(working)} loadingText="…" onClick={() => deleteEntry(entry)}>
|
|
||||||
✕
|
|
||||||
</LoadingButton>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,36 +1,83 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
const FilterIcon = () => (
|
const FilterIcon = () => (
|
||||||
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
<svg viewBox="0 0 16 16" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M2 4h12M4 8h8M6 12h4"/>
|
<path d="M2 4h12M4 8h8M6 12h4"/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default function FilterDropdown({ value, onChange, options }) {
|
export default function FilterDropdown({ value, onChange, options }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const ref = useRef(null);
|
const [pos, setPos] = useState(null);
|
||||||
|
const btnRef = useRef(null);
|
||||||
|
const menuRef = useRef(null);
|
||||||
|
const active = value && value !== 'all';
|
||||||
|
|
||||||
|
const place = useCallback(() => {
|
||||||
|
const r = btnRef.current?.getBoundingClientRect();
|
||||||
|
if (!r) return;
|
||||||
|
const left = Math.min(r.left, window.innerWidth - 176);
|
||||||
|
setPos({ top: r.bottom + 4, left: Math.max(8, left) });
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
function handler(e) { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }
|
function onDown(e) {
|
||||||
document.addEventListener('mousedown', handler);
|
if (btnRef.current?.contains(e.target) || menuRef.current?.contains(e.target)) return;
|
||||||
return () => document.removeEventListener('mousedown', handler);
|
setOpen(false);
|
||||||
}, [open]);
|
}
|
||||||
const current = options.find(o => o.value === value);
|
function onScroll(e) {
|
||||||
|
// Don't close when scrolling inside the menu itself.
|
||||||
|
if (menuRef.current && menuRef.current.contains(e.target)) return;
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onDown);
|
||||||
|
window.addEventListener('scroll', onScroll, true);
|
||||||
|
window.addEventListener('resize', onScroll);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onDown);
|
||||||
|
window.removeEventListener('scroll', onScroll, true);
|
||||||
|
window.removeEventListener('resize', onScroll);
|
||||||
|
};
|
||||||
|
}, [open, place]);
|
||||||
|
|
||||||
return (
|
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 }}>
|
<button
|
||||||
|
ref={btnRef}
|
||||||
|
type="button"
|
||||||
|
onClick={() => { if (!open) place(); setOpen(o => !o); }}
|
||||||
|
aria-label="Filter"
|
||||||
|
title="Filter"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: 'none', border: 'none', padding: 0, cursor: 'pointer',
|
||||||
|
color: active ? 'var(--accent)' : 'var(--text-primary)',
|
||||||
|
opacity: active || open ? 0.9 : 0.35,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<FilterIcon />
|
<FilterIcon />
|
||||||
{current?.label}
|
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && pos && createPortal(
|
||||||
<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
|
||||||
|
ref={menuRef}
|
||||||
|
className="site-header-avatar-menu"
|
||||||
|
style={{ position: 'fixed', top: pos.top, left: pos.left, right: 'auto', width: 'max-content', minWidth: 160, maxHeight: `calc(100vh - ${pos.top}px - 16px)`, overflowY: 'auto', zIndex: 4000 }}
|
||||||
|
>
|
||||||
{options.map(opt => (
|
{options.map(opt => (
|
||||||
<button key={opt.value} onClick={() => { onChange(opt.value); setOpen(false); }} style={{ display: 'block', width: '100%', padding: '7px 14px', textAlign: 'left', background: value === opt.value ? 'rgba(245,165,35,0.08)' : 'transparent', fontSize: 13, color: value === opt.value ? 'var(--accent)' : 'var(--text-primary)', border: 'none', cursor: 'pointer' }}>
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
className="site-header-avatar-item"
|
||||||
|
onClick={() => { onChange(opt.value); setOpen(false); }}
|
||||||
|
style={value === opt.value ? { color: 'var(--accent)' } : undefined}
|
||||||
|
>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
|
||||||
|
import PageLoader from './PageLoader';
|
||||||
|
|
||||||
|
export default function InvoiceDetailPopup({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
headerRight,
|
||||||
|
metaContent, // JSX fields rendered in flat meta grid (no cards)
|
||||||
|
metaActions, // optional JSX rendered right-aligned beside meta grid (e.g. Edit Dates)
|
||||||
|
metaCols = 4, // number of grid columns for meta strip
|
||||||
|
footerActions,
|
||||||
|
loading = false,
|
||||||
|
children,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div style={popupOverlayStyle}>
|
||||||
|
<div
|
||||||
|
style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16, paddingBottom: 18, borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 500, color: 'var(--text-primary)', letterSpacing: 0.2, lineHeight: 1.1 }}>{title}</div>
|
||||||
|
{subtitle && <div style={{ marginTop: 6, fontSize: 13, color: 'var(--text-secondary)' }}>{subtitle}</div>}
|
||||||
|
</div>
|
||||||
|
{headerRight && <div style={{ flexShrink: 0 }}>{headerRight}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Flat meta strip */}
|
||||||
|
{metaContent && (
|
||||||
|
<div style={{ padding: '18px 0', borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${metaCols}, minmax(0, 1fr))`, gap: '14px 18px', flex: 1 }}>
|
||||||
|
{metaContent}
|
||||||
|
</div>
|
||||||
|
{metaActions && (
|
||||||
|
<div style={{ flexShrink: 0, display: 'flex', gap: 8, alignItems: 'flex-start' }}>
|
||||||
|
{metaActions}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ flex: 1, minHeight: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<PageLoader />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto', paddingTop: 18 }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
{footerActions && (
|
||||||
|
<div className="modal-action-row" style={{ paddingTop: 18, borderTop: '1px solid var(--border)', flexShrink: 0 }}>
|
||||||
|
{footerActions}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import SortTh from './SortTh';
|
||||||
|
|
||||||
|
function fmt(val) {
|
||||||
|
return `$${Number(val || 0).toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferItemType(item) {
|
||||||
|
if (item._inferredType) return item._inferredType;
|
||||||
|
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 InvoicePopupTable({ sortKey, sortDir, onSort, items, notes }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="card-empty-center" style={{ minHeight: 120 }}>No line items.</div>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
<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 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' }}>{fmt(item.unit_price)}</td>
|
||||||
|
<td style={{ padding: '5px 0', fontSize: 13, textAlign: 'right', fontWeight: 400 }}>
|
||||||
|
{fmt(Number(item.unit_price || 0) * Number(item.quantity || 1))}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{notes && (
|
||||||
|
<div style={{ marginTop: 18, padding: '12px 14px', background: 'var(--card-bg-2)', borderRadius: 6, border: '1px solid var(--border)' }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 4 }}>Notes</div>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--text-secondary)', whiteSpace: 'pre-wrap' }}>{notes}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,17 +1,19 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { NavLink, useNavigate, useLocation } from 'react-router-dom';
|
import PageLoader from './PageLoader';
|
||||||
|
import ProfileAvatar from './ProfileAvatar';
|
||||||
|
import { NavLink, Link, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
dashboard: <svg viewBox="0 0 16 16" fill="currentColor"><rect x="1" y="1" width="6" height="6" rx="1.5"/><rect x="9" y="1" width="6" height="6" rx="1.5"/><rect x="1" y="9" width="6" height="6" rx="1.5"/><rect x="9" y="9" width="6" height="6" rx="1.5"/></svg>,
|
dashboard: <svg viewBox="0 0 16 16" fill="currentColor"><rect x="1" y="1" width="6" height="6" rx="1.5"/><rect x="9" y="1" width="6" height="6" rx="1.5"/><rect x="1" y="9" width="6" height="6" rx="1.5"/><rect x="9" y="9" width="6" height="6" rx="1.5"/></svg>,
|
||||||
requests: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><rect x="2" y="2" width="12" height="12" rx="1.5"/><line x1="5" y1="5.5" x2="11" y2="5.5"/><line x1="5" y1="8" x2="11" y2="8"/><line x1="5" y1="10.5" x2="8" y2="10.5"/></svg>,
|
requests: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><rect x="2" y="2" width="12" height="12" rx="1.5"/><line x1="5" y1="5.5" x2="11" y2="5.5"/><line x1="5" y1="8" x2="11" y2="8"/><line x1="5" y1="10.5" x2="8" y2="10.5"/></svg>,
|
||||||
projects: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M1.5 5.5C1.5 4.67 2.17 4 3 4h2.5l1.5 2H13c.83 0 1.5.67 1.5 1.5v5.5c0 .83-.67 1.5-1.5 1.5H3c-.83 0-1.5-.67-1.5-1.5V5.5z"/></svg>,
|
projects: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M1.5 5.5C1.5 4.67 2.17 4 3 4h2.5l1.5 2H13c.83 0 1.5.67 1.5 1.5v5.5c0 .83-.67 1.5-1.5 1.5H3c-.83 0-1.5-.67-1.5-1.5V5.5z"/></svg>,
|
||||||
fileSharing: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 2H4a1.5 1.5 0 00-1.5 1.5v9A1.5 1.5 0 004 14h8a1.5 1.5 0 001.5-1.5V6L9 2z"/><polyline points="9,2 9,6 13.5,6"/></svg>,
|
|
||||||
invoices: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><rect x="2" y="1.5" width="12" height="13" rx="1"/><line x1="8" y1="4" x2="8" y2="12"/><path d="M10 5.5H7a1.5 1.5 0 000 3h2a1.5 1.5 0 010 3H5.5"/></svg>,
|
invoices: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><rect x="2" y="1.5" width="12" height="13" rx="1"/><line x1="8" y1="4" x2="8" y2="12"/><path d="M10 5.5H7a1.5 1.5 0 000 3h2a1.5 1.5 0 010 3H5.5"/></svg>,
|
||||||
notes: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><rect x="3" y="1.5" width="10" height="13" rx="1"/><line x1="5.5" y1="5" x2="10.5" y2="5"/><line x1="5.5" y1="7.5" x2="10.5" y2="7.5"/><line x1="5.5" y1="10" x2="8.5" y2="10"/></svg>,
|
notes: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><rect x="3" y="1.5" width="10" height="13" rx="1"/><line x1="5.5" y1="5" x2="10.5" y2="5"/><line x1="5.5" y1="7.5" x2="10.5" y2="7.5"/><line x1="5.5" y1="10" x2="8.5" y2="10"/></svg>,
|
||||||
survey: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="1.5" y="9.5" width="3" height="5" rx="0.5"/><rect x="6.5" y="6" width="3" height="8.5" rx="0.5"/><rect x="11.5" y="2.5" width="3" height="12" rx="0.5"/></svg>,
|
survey: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="1.5" y="9.5" width="3" height="5" rx="0.5"/><rect x="6.5" y="6" width="3" height="8.5" rx="0.5"/><rect x="11.5" y="2.5" width="3" height="12" rx="0.5"/></svg>,
|
||||||
brandBook: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M2.5 2.5h4a2 2 0 012 2v9a2 2 0 00-2-2h-4V2.5z"/><path d="M13.5 2.5h-4a2 2 0 00-2 2v9a2 2 0 012-2h4V2.5z"/></svg>,
|
brandBook: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M2.5 2.5h4a2 2 0 012 2v9a2 2 0 00-2-2h-4V2.5z"/><path d="M13.5 2.5h-4a2 2 0 00-2 2v9a2 2 0 012-2h4V2.5z"/></svg>,
|
||||||
converter: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="1.5" y="1.5" width="13" height="13" rx="1.5"/><circle cx="5.5" cy="5.5" r="1.5"/><path d="M1.5 11.5l3.5-3.5 3 3L11 7.5l3 3"/></svg>,
|
converter: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="1.5" y="1.5" width="13" height="13" rx="1.5"/><circle cx="5.5" cy="5.5" r="1.5"/><path d="M1.5 11.5l3.5-3.5 3 3L11 7.5l3 3"/></svg>,
|
||||||
|
reports: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M2.5 13.5h11"/><path d="M4.5 11V7.5"/><path d="M8 11V4.5"/><path d="M11.5 11V6"/></svg>,
|
||||||
passwords: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="7" width="10" height="7.5" rx="1"/><path d="M5 7V5.5a3 3 0 016 0V7"/><circle cx="8" cy="10.5" r="1" fill="currentColor" stroke="none"/></svg>,
|
passwords: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="7" width="10" height="7.5" rx="1"/><path d="M5 7V5.5a3 3 0 016 0V7"/><circle cx="8" cy="10.5" r="1" fill="currentColor" stroke="none"/></svg>,
|
||||||
companies: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="1.5" y="5" width="13" height="9.5" rx="1"/><path d="M5.5 5V3a1 1 0 011-1h3a1 1 0 011 1v2"/><line x1="8" y1="5" x2="8" y2="14.5"/><line x1="1.5" y1="9" x2="14.5" y2="9"/></svg>,
|
companies: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="1.5" y="5" width="13" height="9.5" rx="1"/><path d="M5.5 5V3a1 1 0 011-1h3a1 1 0 011 1v2"/><line x1="8" y1="5" x2="8" y2="14.5"/><line x1="1.5" y1="9" x2="14.5" y2="9"/></svg>,
|
||||||
users: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><circle cx="6" cy="5" r="2.5"/><path d="M1 14c0-2.76 2.24-5 5-5s5 2.24 5 5"/><circle cx="12.5" cy="5.5" r="2"/><path d="M12.5 10c1.93 0 3.5 1.57 3.5 3.5"/></svg>,
|
users: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><circle cx="6" cy="5" r="2.5"/><path d="M1 14c0-2.76 2.24-5 5-5s5 2.24 5 5"/><circle cx="12.5" cy="5.5" r="2"/><path d="M12.5 10c1.93 0 3.5 1.57 3.5 3.5"/></svg>,
|
||||||
@@ -25,34 +27,32 @@ function TeamNav({ onNav }) {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const primaryLinks = [
|
const primaryLinks = [
|
||||||
{ to: '/team/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
|
{ to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
|
||||||
{ to: '/requests', label: 'Requests', icon: ICONS.requests },
|
{ to: '/tasks', label: 'Tasks', icon: ICONS.requests },
|
||||||
{ to: '/projects', label: 'Projects', icon: ICONS.projects },
|
{ to: '/finances', label: 'Finances', icon: ICONS.invoices },
|
||||||
{ to: '/invoices', label: 'Finances', icon: ICONS.invoices },
|
|
||||||
{ to: '/file-sharing', label: 'File Sharing', icon: ICONS.fileSharing },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const utilityLinks = [
|
const utilityLinks = [
|
||||||
{ to: '/survey-maker', label: 'Survey Maker', icon: ICONS.survey },
|
{ to: '/survey-maker', label: 'Survey Maker', icon: ICONS.survey },
|
||||||
{ to: '/brand-book', label: 'Brand Book Maker', icon: ICONS.brandBook },
|
{ to: '/brand-book', label: 'Brand Book Maker', icon: ICONS.brandBook },
|
||||||
{ to: '/converters', label: 'Image Converter', icon: ICONS.converter },
|
{ to: '/converters', label: 'Image Converter', icon: ICONS.converter },
|
||||||
|
{ to: '/reports', label: 'Reports', icon: ICONS.reports },
|
||||||
{ to: '/fourge-passwords', label: 'Fourge Passwords', icon: ICONS.passwords },
|
{ to: '/fourge-passwords', label: 'Fourge Passwords', icon: ICONS.passwords },
|
||||||
];
|
];
|
||||||
|
|
||||||
const isCompaniesActive = location.pathname === '/company' && !location.search.includes('tab=users');
|
const isCompaniesActive = location.pathname === '/company' && !location.search.includes('tab=users');
|
||||||
const isUsersActive = location.pathname === '/company' && location.search.includes('tab=users');
|
const isUsersActive = location.pathname === '/company' && location.search.includes('tab=users');
|
||||||
|
const isTasksActive = location.pathname === '/tasks' || location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/clients/') || location.pathname.startsWith('/requests/');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sidebar-section">
|
<div className="sidebar-section">
|
||||||
{primaryLinks.map(({ to, label, icon }) => (
|
{primaryLinks.map(({ to, label, icon }) => (
|
||||||
<NavLink key={to} to={to} onClick={onNav} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
|
<NavLink key={to} to={to} onClick={onNav} className={to === '/tasks' ? () => `sidebar-link${isTasksActive ? ' active' : ''}` : ({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
|
||||||
<NI icon={icon} /><span className="nav-label">{label}</span>
|
<NI icon={icon} /><span className="nav-label">{label}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
<div className="sidebar-tools-divider" style={{ height: 1, margin: '10px 12px', background: 'var(--border)' }} />
|
<div className="sidebar-tools-divider" />
|
||||||
<div className="sidebar-tools-label" style={{ padding: '0 12px 8px', fontSize: 11, fontWeight: 400, letterSpacing: 0.8, textTransform: 'uppercase', color: 'var(--text-muted)' }}>
|
<div className="sidebar-tools-label">Tools</div>
|
||||||
Tools
|
|
||||||
</div>
|
|
||||||
{utilityLinks.map(({ to, label, icon }) => (
|
{utilityLinks.map(({ to, label, icon }) => (
|
||||||
<NavLink key={to} to={to} onClick={onNav} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
|
<NavLink key={to} to={to} onClick={onNav} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
|
||||||
<NI icon={icon} /><span className="nav-label">{label}</span>
|
<NI icon={icon} /><span className="nav-label">{label}</span>
|
||||||
@@ -69,18 +69,18 @@ function TeamNav({ onNav }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ClientNav({ onNav }) {
|
function ClientNav({ onNav }) {
|
||||||
|
const location = useLocation();
|
||||||
|
const isTasksActive = location.pathname === '/tasks' || location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/clients/') || location.pathname.startsWith('/requests/');
|
||||||
const links = [
|
const links = [
|
||||||
{ to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
|
{ to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
|
||||||
{ to: '/requests', label: 'Requests', icon: ICONS.requests },
|
{ to: '/tasks', label: 'Tasks', icon: ICONS.requests },
|
||||||
{ to: '/projects', label: 'Projects', icon: ICONS.projects },
|
{ to: '/client-invoices', label: 'Invoices', icon: ICONS.invoices },
|
||||||
{ to: '/file-sharing', label: 'File Sharing', icon: ICONS.fileSharing },
|
|
||||||
{ to: '/my-invoices', label: 'Invoices', icon: ICONS.invoices },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sidebar-section">
|
<div className="sidebar-section">
|
||||||
{links.map(({ to, label, icon }) => (
|
{links.map(({ to, label, icon }) => (
|
||||||
<NavLink key={label} to={to} onClick={onNav} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
|
<NavLink key={label} to={to} onClick={onNav} className={to === '/tasks' ? () => `sidebar-link${isTasksActive ? ' active' : ''}` : ({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
|
||||||
<NI icon={icon} /><span className="nav-label">{label}</span>
|
<NI icon={icon} /><span className="nav-label">{label}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
@@ -89,12 +89,12 @@ function ClientNav({ onNav }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ExternalNav({ onNav }) {
|
function ExternalNav({ onNav }) {
|
||||||
|
const location = useLocation();
|
||||||
|
const isTasksActive = location.pathname === '/tasks' || location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/clients/') || location.pathname.startsWith('/requests/');
|
||||||
const links = [
|
const links = [
|
||||||
{ to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
|
{ to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
|
||||||
{ to: '/requests', label: 'Requests', icon: ICONS.requests },
|
{ to: '/tasks', label: 'Tasks', icon: ICONS.requests },
|
||||||
{ to: '/projects', label: 'Projects', icon: ICONS.projects },
|
{ to: '/subs-invoices', label: 'Invoices', icon: ICONS.invoices },
|
||||||
{ to: '/my-invoices-sub', label: 'Invoices', icon: ICONS.invoices },
|
|
||||||
{ to: '/file-sharing', label: 'File Sharing', icon: ICONS.fileSharing },
|
|
||||||
{ to: '/survey-maker', label: 'Survey Maker', icon: ICONS.survey },
|
{ to: '/survey-maker', label: 'Survey Maker', icon: ICONS.survey },
|
||||||
{ to: '/brand-book', label: 'Brand Book Maker', icon: ICONS.brandBook },
|
{ to: '/brand-book', label: 'Brand Book Maker', icon: ICONS.brandBook },
|
||||||
{ to: '/converters', label: 'Image Converter', icon: ICONS.converter },
|
{ to: '/converters', label: 'Image Converter', icon: ICONS.converter },
|
||||||
@@ -106,13 +106,11 @@ function ExternalNav({ onNav }) {
|
|||||||
<div key={to}>
|
<div key={to}>
|
||||||
{index === 4 && (
|
{index === 4 && (
|
||||||
<>
|
<>
|
||||||
<div className="sidebar-tools-divider" style={{ height: 1, margin: '10px 12px', background: 'var(--border)' }} />
|
<div className="sidebar-tools-divider" />
|
||||||
<div className="sidebar-tools-label" style={{ padding: '0 12px 8px', fontSize: 11, fontWeight: 400, letterSpacing: 0.8, textTransform: 'uppercase', color: 'var(--text-muted)' }}>
|
<div className="sidebar-tools-label">Tools</div>
|
||||||
Tools
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<NavLink to={to} onClick={onNav} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
|
<NavLink to={to} onClick={onNav} className={to === '/tasks' ? () => `sidebar-link${isTasksActive ? ' active' : ''}` : ({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
|
||||||
<NI icon={icon} /><span className="nav-label">{label}</span>
|
<NI icon={icon} /><span className="nav-label">{label}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,16 +119,22 @@ function ExternalNav({ onNav }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Layout({ children }) {
|
function getInitialTheme() {
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
return document.documentElement.getAttribute('data-theme') || localStorage.getItem('theme') || 'dark';
|
||||||
|
}
|
||||||
|
return 'dark';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Layout({ children, loading = false }) {
|
||||||
const { currentUser, logout } = useAuth();
|
const { currentUser, logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [theme, setTheme] = useState(() => localStorage.getItem('theme') || 'dark');
|
const [theme, setTheme] = useState(getInitialTheme);
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [searchResults, setSearchResults] = useState({ projects: [], tasks: [] });
|
const [searchResults, setSearchResults] = useState({ projects: [], tasks: [], companies: [], people: [] });
|
||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
const [searchExpanded, setSearchExpanded] = useState(false);
|
|
||||||
const searchInputRef = useRef(null);
|
const searchInputRef = useRef(null);
|
||||||
const [avatarOpen, setAvatarOpen] = useState(false);
|
const [avatarOpen, setAvatarOpen] = useState(false);
|
||||||
const avatarRef = useRef(null);
|
const avatarRef = useRef(null);
|
||||||
@@ -139,25 +143,72 @@ export default function Layout({ children }) {
|
|||||||
const timeOfDay = hour < 12 ? 'morning' : hour < 17 ? 'afternoon' : 'evening';
|
const timeOfDay = hour < 12 ? 'morning' : hour < 17 ? 'afternoon' : 'evening';
|
||||||
const firstName = currentUser?.name?.split(' ')[0] || '';
|
const firstName = currentUser?.name?.split(' ')[0] || '';
|
||||||
const isProfileRoute = location.pathname === '/profile' || location.pathname.startsWith('/profile/');
|
const isProfileRoute = location.pathname === '/profile' || location.pathname.startsWith('/profile/');
|
||||||
const headerTitle = isProfileRoute ? 'Profile' : `Good ${timeOfDay}${firstName ? `, ${firstName}` : ''}`;
|
const isTaskDetailRoute = location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/clients/') || 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 === '/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' : !isTaskDetailRoute && !isInvoiceDetailRoute ? `Good ${timeOfDay}${firstName ? `, ${firstName}` : ''}` : null;
|
||||||
const headerSubtitle = isProfileRoute
|
const headerSubtitle = isProfileRoute
|
||||||
? 'Account details and security settings.'
|
? 'Account details and security settings.'
|
||||||
: "Here's what's happening today.";
|
: 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 clients.'
|
||||||
|
: 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' : financeHeaderTitle;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.setAttribute('data-theme', theme);
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
document.documentElement.style.colorScheme = theme === 'light' ? 'light' : 'dark';
|
||||||
localStorage.setItem('theme', theme);
|
localStorage.setItem('theme', theme);
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!searchQuery.trim()) { setSearchResults({ projects: [], tasks: [] }); return; }
|
if (!searchQuery.trim()) { setSearchResults({ projects: [], tasks: [], companies: [], people: [] }); return; }
|
||||||
const t = setTimeout(async () => {
|
const t = setTimeout(async () => {
|
||||||
const { supabase } = await import('../lib/supabase');
|
const { supabase } = await import('../lib/supabase');
|
||||||
const [{ data: projects }, { data: tasks }] = await Promise.all([
|
const [{ data: projects }, { data: tasks }, { data: companies }, { data: people }] = await Promise.all([
|
||||||
supabase.from('projects').select('id, name').ilike('name', `%${searchQuery}%`).limit(6),
|
supabase.from('projects').select('id, name').ilike('name', `%${searchQuery}%`).limit(6),
|
||||||
supabase.from('tasks').select('id, title').ilike('title', `%${searchQuery}%`).limit(6),
|
supabase.from('tasks').select('id, title').ilike('title', `%${searchQuery}%`).limit(6),
|
||||||
|
supabase.from('companies').select('id, name').ilike('name', `%${searchQuery}%`).limit(6),
|
||||||
|
supabase.from('profiles').select('id, name').ilike('name', `%${searchQuery}%`).limit(6),
|
||||||
]);
|
]);
|
||||||
setSearchResults({ projects: projects || [], tasks: tasks || [] });
|
setSearchResults({ projects: projects || [], tasks: tasks || [], companies: companies || [], people: people || [] });
|
||||||
}, 280);
|
}, 280);
|
||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}, [searchQuery]);
|
}, [searchQuery]);
|
||||||
@@ -171,6 +222,9 @@ export default function Layout({ children }) {
|
|||||||
return () => document.removeEventListener('mousedown', handler);
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
}, [avatarOpen]);
|
}, [avatarOpen]);
|
||||||
|
|
||||||
|
const [navigating, setNavigating] = useState(false);
|
||||||
|
const navTimerRef = useRef(null);
|
||||||
|
|
||||||
const [lastPathname, setLastPathname] = useState(location.pathname);
|
const [lastPathname, setLastPathname] = useState(location.pathname);
|
||||||
if (lastPathname !== location.pathname) {
|
if (lastPathname !== location.pathname) {
|
||||||
setLastPathname(location.pathname);
|
setLastPathname(location.pathname);
|
||||||
@@ -178,13 +232,25 @@ export default function Layout({ children }) {
|
|||||||
setAvatarOpen(false);
|
setAvatarOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNavigating(true);
|
||||||
|
clearTimeout(navTimerRef.current);
|
||||||
|
navTimerRef.current = setTimeout(() => setNavigating(false), 500);
|
||||||
|
return () => clearTimeout(navTimerRef.current);
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
const toggleTheme = () => setTheme(t => t === 'dark' ? 'light' : 'dark');
|
const toggleTheme = () => setTheme(t => t === 'dark' ? 'light' : 'dark');
|
||||||
const handleLogout = async () => { await logout(); navigate('/'); };
|
const handleLogout = async () => { await logout(); navigate('/'); };
|
||||||
|
|
||||||
const hasResults = searchResults.projects.length > 0 || searchResults.tasks.length > 0;
|
const hasResults =
|
||||||
|
searchResults.projects.length > 0 ||
|
||||||
|
searchResults.tasks.length > 0 ||
|
||||||
|
searchResults.companies.length > 0 ||
|
||||||
|
searchResults.people.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-layout">
|
<div className="app-layout">
|
||||||
|
{(navigating || loading) && <PageLoader />}
|
||||||
{menuOpen && <div className="sidebar-overlay" onClick={() => setMenuOpen(false)} />}
|
{menuOpen && <div className="sidebar-overlay" onClick={() => setMenuOpen(false)} />}
|
||||||
|
|
||||||
<aside className={`sidebar${menuOpen ? ' sidebar-open' : ''}`}>
|
<aside className={`sidebar${menuOpen ? ' sidebar-open' : ''}`}>
|
||||||
@@ -199,6 +265,27 @@ export default function Layout({ children }) {
|
|||||||
: <ClientNav onNav={() => setMenuOpen(false)} />
|
: <ClientNav onNav={() => setMenuOpen(false)} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<div className="sidebar-mobile-footer">
|
||||||
|
<button className="sidebar-link" onClick={() => { navigate('/profile'); setMenuOpen(false); }} title="Profile">
|
||||||
|
<ProfileAvatar profile={currentUser} name={currentUser?.name} size={26} fontSize={10} />
|
||||||
|
<span className="nav-label">Profile</span>
|
||||||
|
</button>
|
||||||
|
<button className="sidebar-link" onClick={toggleTheme} title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}>
|
||||||
|
<span className="nav-icon">
|
||||||
|
{theme === 'dark'
|
||||||
|
? <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="8" cy="8" r="3"/><line x1="8" y1="1" x2="8" y2="2.5"/><line x1="8" y1="13.5" x2="8" y2="15"/><line x1="1" y1="8" x2="2.5" y2="8"/><line x1="13.5" y1="8" x2="15" y2="8"/><line x1="3" y1="3" x2="4.1" y2="4.1"/><line x1="11.9" y1="11.9" x2="13" y2="13"/><line x1="13" y1="3" x2="11.9" y2="4.1"/><line x1="4.1" y1="11.9" x2="3" y2="13"/></svg>
|
||||||
|
: <svg viewBox="0 0 16 16" fill="currentColor" stroke="none"><path d="M14 8.53A6 6 0 1 1 7.47 2 4.67 4.67 0 0 0 14 8.53Z"/></svg>}
|
||||||
|
</span>
|
||||||
|
<span className="nav-label">{theme === 'dark' ? 'Light mode' : 'Dark mode'}</span>
|
||||||
|
</button>
|
||||||
|
<button className="sidebar-link" onClick={handleLogout} title="Sign Out">
|
||||||
|
<span className="nav-icon">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M6 14H3a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1h3"/><polyline points="10 11 13 8 10 5"/><line x1="13" y1="8" x2="6" y2="8"/></svg>
|
||||||
|
</span>
|
||||||
|
<span className="nav-label">Sign Out</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div className="main-wrapper">
|
<div className="main-wrapper">
|
||||||
@@ -206,40 +293,42 @@ export default function Layout({ children }) {
|
|||||||
<button className="hamburger" onClick={() => setMenuOpen(o => !o)} aria-label="Menu">
|
<button className="hamburger" onClick={() => setMenuOpen(o => !o)} aria-label="Menu">
|
||||||
<span /><span /><span />
|
<span /><span /><span />
|
||||||
</button>
|
</button>
|
||||||
<img className="brand-logo brand-logo-mobile" src="/fourge-logo.png" alt="Fourge Branding" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main className="main-content">
|
<main className="main-content">
|
||||||
<div className="site-header">
|
<div className="site-header">
|
||||||
<div>
|
<div>
|
||||||
<div className="site-header-greeting">{headerTitle}</div>
|
{isTaskDetailRoute || isInvoiceDetailRoute ? (
|
||||||
<div className="site-header-sub">{headerSubtitle}</div>
|
<div>
|
||||||
|
<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 }}>{detailBackLabel}</span>
|
||||||
|
</Link>
|
||||||
|
<div className="site-header-sub" style={{ paddingLeft: 25 }}>Return back to previous page</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="site-header-greeting">{headerTitle}</div>
|
||||||
|
<div className="site-header-sub">{headerSubtitle}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="site-header-right">
|
<div className="site-header-right">
|
||||||
<div className="site-header-search-wrap">
|
<div className="site-header-search-wrap">
|
||||||
{!searchExpanded ? (
|
<span className="site-header-search-icon" aria-hidden="true">
|
||||||
<button
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><circle cx="6.5" cy="6.5" r="4"/><line x1="10" y1="10" x2="14" y2="14"/></svg>
|
||||||
className="site-header-search-btn"
|
</span>
|
||||||
onClick={() => { setSearchExpanded(true); setTimeout(() => searchInputRef.current?.focus(), 50); }}
|
<input
|
||||||
aria-label="Search"
|
ref={searchInputRef}
|
||||||
>
|
className="site-header-search"
|
||||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><circle cx="6.5" cy="6.5" r="4"/><line x1="10" y1="10" x2="14" y2="14"/></svg>
|
type="text"
|
||||||
</button>
|
placeholder="Search"
|
||||||
) : (
|
value={searchQuery}
|
||||||
<>
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
<input
|
onFocus={() => setSearchOpen(true)}
|
||||||
ref={searchInputRef}
|
onBlur={() => setTimeout(() => { setSearchOpen(false); setSearchQuery(''); }, 150)}
|
||||||
className="site-header-search"
|
onKeyDown={e => { if (e.key === 'Escape') { setSearchOpen(false); setSearchQuery(''); } }}
|
||||||
type="text"
|
/>
|
||||||
placeholder="Search projects & tasks..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={e => setSearchQuery(e.target.value)}
|
|
||||||
onFocus={() => setSearchOpen(true)}
|
|
||||||
onBlur={() => setTimeout(() => { setSearchOpen(false); setSearchExpanded(false); setSearchQuery(''); }, 150)}
|
|
||||||
onKeyDown={e => { if (e.key === 'Escape') { setSearchOpen(false); setSearchExpanded(false); setSearchQuery(''); } }}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{searchOpen && searchQuery.trim() && (
|
{searchOpen && searchQuery.trim() && (
|
||||||
<div className="site-header-dropdown">
|
<div className="site-header-dropdown">
|
||||||
{!hasResults && <div className="site-header-dropdown-empty">No results for "{searchQuery}"</div>}
|
{!hasResults && <div className="site-header-dropdown-empty">No results for "{searchQuery}"</div>}
|
||||||
@@ -247,7 +336,7 @@ export default function Layout({ children }) {
|
|||||||
<>
|
<>
|
||||||
<div className="site-header-dropdown-group">Projects</div>
|
<div className="site-header-dropdown-group">Projects</div>
|
||||||
{searchResults.projects.map(p => (
|
{searchResults.projects.map(p => (
|
||||||
<div key={p.id} className="site-header-dropdown-item" onMouseDown={() => { navigate(`/projects/${p.id}`); setSearchQuery(''); setSearchOpen(false); setSearchExpanded(false); }}>
|
<div key={p.id} className="site-header-dropdown-item" onMouseDown={() => { navigate(`/projects/${p.id}`); setSearchQuery(''); setSearchOpen(false); }}>
|
||||||
<span className="nav-icon" style={{ opacity: 0.6 }}>{ICONS.projects}</span>
|
<span className="nav-icon" style={{ opacity: 0.6 }}>{ICONS.projects}</span>
|
||||||
{p.name}
|
{p.name}
|
||||||
</div>
|
</div>
|
||||||
@@ -258,13 +347,35 @@ export default function Layout({ children }) {
|
|||||||
<>
|
<>
|
||||||
<div className="site-header-dropdown-group">Tasks</div>
|
<div className="site-header-dropdown-group">Tasks</div>
|
||||||
{searchResults.tasks.map(r => (
|
{searchResults.tasks.map(r => (
|
||||||
<div key={r.id} className="site-header-dropdown-item" onMouseDown={() => { navigate(`/requests/${r.id}`); setSearchQuery(''); setSearchOpen(false); setSearchExpanded(false); }}>
|
<div key={r.id} className="site-header-dropdown-item" onMouseDown={() => { navigate(`/tasks/${r.id}`); setSearchQuery(''); setSearchOpen(false); }}>
|
||||||
<span className="nav-icon" style={{ opacity: 0.6 }}>{ICONS.requests}</span>
|
<span className="nav-icon" style={{ opacity: 0.6 }}>{ICONS.requests}</span>
|
||||||
{r.title}
|
{r.title}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{searchResults.companies.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="site-header-dropdown-group">Companies</div>
|
||||||
|
{searchResults.companies.map(c => (
|
||||||
|
<div key={c.id} className="site-header-dropdown-item" onMouseDown={() => { navigate(`/company/${c.id}`); setSearchQuery(''); setSearchOpen(false); }}>
|
||||||
|
<span className="nav-icon" style={{ opacity: 0.6 }}>{ICONS.companies}</span>
|
||||||
|
{c.name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{searchResults.people.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="site-header-dropdown-group">People</div>
|
||||||
|
{searchResults.people.map(p => (
|
||||||
|
<div key={p.id} className="site-header-dropdown-item" onMouseDown={() => { navigate(`/profile/${p.id}`); setSearchQuery(''); setSearchOpen(false); }}>
|
||||||
|
<span className="nav-icon" style={{ opacity: 0.6 }}>{ICONS.users}</span>
|
||||||
|
{p.name || 'Unknown User'}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -277,12 +388,12 @@ export default function Layout({ children }) {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="site-header-avatar-wrap" ref={avatarRef}>
|
<div className="site-header-avatar-wrap" ref={avatarRef}>
|
||||||
<button className="site-header-avatar-btn" onClick={() => setAvatarOpen(o => !o)} aria-label="Account menu">
|
<button className="site-header-avatar-btn" onClick={() => setAvatarOpen(o => !o)} aria-label="Account menu" style={{ overflow: 'hidden' }}>
|
||||||
<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>
|
</button>
|
||||||
{avatarOpen && (
|
{avatarOpen && (
|
||||||
<div className="site-header-avatar-menu">
|
<div className="site-header-avatar-menu">
|
||||||
<button className="site-header-avatar-item" onClick={() => { navigate('/profile'); setAvatarOpen(false); }}>Settings</button>
|
<button className="site-header-avatar-item" onClick={() => { navigate('/profile'); setAvatarOpen(false); }}>Profile</button>
|
||||||
<div className="site-header-avatar-divider" />
|
<div className="site-header-avatar-divider" />
|
||||||
<button className="site-header-avatar-item" onClick={handleLogout}>Sign Out</button>
|
<button className="site-header-avatar-item" onClick={handleLogout}>Sign Out</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,15 +1,44 @@
|
|||||||
export default function PageLoader({ opacity = 0.6 }) {
|
export default function PageLoader({ label = 'Loading', progress = null, scope = 'page' }) {
|
||||||
|
const hasProgress = typeof progress === 'number' && Number.isFinite(progress);
|
||||||
|
const isContainerScope = scope === 'container';
|
||||||
return (
|
return (
|
||||||
<div
|
<div style={{
|
||||||
style={{
|
position: isContainerScope ? 'absolute' : 'fixed',
|
||||||
minHeight: '100vh',
|
inset: 0,
|
||||||
background: 'var(--bg, #111)',
|
zIndex: isContainerScope ? 20 : 1200,
|
||||||
display: 'flex',
|
background: 'var(--overlay-scrim)',
|
||||||
alignItems: 'center',
|
backdropFilter: 'blur(6px)',
|
||||||
justifyContent: 'center',
|
WebkitBackdropFilter: 'blur(6px)',
|
||||||
}}
|
display: 'flex',
|
||||||
>
|
alignItems: 'center',
|
||||||
<img src="/fourge-logo.png" alt="Loading" style={{ width: 160, opacity }} />
|
justifyContent: 'center',
|
||||||
|
padding: 24,
|
||||||
|
borderRadius: isContainerScope ? 'inherit' : 0,
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--popup-bg)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '18px 21px',
|
||||||
|
backdropFilter: 'blur(12px)',
|
||||||
|
WebkitBackdropFilter: 'blur(12px)',
|
||||||
|
width: 'min(320px, 100%)',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 14 }}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<>
|
||||||
|
<div style={{ height: 4, borderRadius: 2, background: 'var(--card-bg-2)', overflow: 'hidden', marginBottom: hasProgress ? 8 : 0 }}>
|
||||||
|
<div
|
||||||
|
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>
|
||||||
|
{hasProgress && (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-secondary)', textAlign: 'right' }}>{progress}%</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
function normalizeName(value) {
|
||||||
|
return String(value || '').trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,10 +5,15 @@ import FileAttachment from './FileAttachment';
|
|||||||
import { addDaysToDateOnly, getTodayDateOnlyEST } from '../lib/dates';
|
import { addDaysToDateOnly, getTodayDateOnlyEST } from '../lib/dates';
|
||||||
|
|
||||||
const defaultDeadline = () => addDaysToDateOnly(getTodayDateOnlyEST(), 3);
|
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 = '') => ({
|
const emptyForm = (companyId = '') => ({
|
||||||
companyId,
|
companyId,
|
||||||
project: '',
|
project: '',
|
||||||
serviceType: '',
|
serviceType: '',
|
||||||
|
signFamily: '',
|
||||||
title: '',
|
title: '',
|
||||||
deadline: defaultDeadline(),
|
deadline: defaultDeadline(),
|
||||||
description: '',
|
description: '',
|
||||||
@@ -36,19 +41,31 @@ export default function RequestForm({
|
|||||||
error = '',
|
error = '',
|
||||||
submitLabel = 'Submit Request',
|
submitLabel = 'Submit Request',
|
||||||
initialCompanyId = '',
|
initialCompanyId = '',
|
||||||
|
initialValues = null,
|
||||||
|
lockedFields = [],
|
||||||
}) {
|
}) {
|
||||||
const [form, setForm] = useState(() => emptyForm(initialCompanyId));
|
const [form, setForm] = useState(() => ({
|
||||||
|
...emptyForm(initialCompanyId),
|
||||||
|
requestedBy: showRequester ? (currentUser?.id || '') : '',
|
||||||
|
...(initialValues || {}),
|
||||||
|
}));
|
||||||
const [files, setFiles] = useState([]);
|
const [files, setFiles] = useState([]);
|
||||||
const [existingProjects, setExistingProjects] = useState([]);
|
const [existingProjects, setExistingProjects] = useState([]);
|
||||||
const [customProjects, setCustomProjects] = useState([]);
|
const [customProjects, setCustomProjects] = useState([]);
|
||||||
const [isTypingProject, setIsTypingProject] = useState(false);
|
const [isTypingProject, setIsTypingProject] = useState(false);
|
||||||
const [newProjectName, setNewProjectName] = useState('');
|
const [newProjectName, setNewProjectName] = useState('');
|
||||||
const [companyUsers, setCompanyUsers] = useState([]);
|
const [companyUsers, setCompanyUsers] = useState([]);
|
||||||
|
const [signCount, setSignCount] = useState('');
|
||||||
|
const [signs, setSigns] = useState([]);
|
||||||
|
const [localError, setLocalError] = useState('');
|
||||||
|
|
||||||
const companyId = form.companyId || initialCompanyId;
|
const usesSignFields = form.serviceType === 'Brand Book';
|
||||||
|
|
||||||
|
// Single-company user: lock selection to their one company (derived, not stored).
|
||||||
|
const companyId = form.companyId || (companies.length === 1 ? companies[0].id : '') || initialCompanyId;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!companyId) { setExistingProjects([]); setCompanyUsers([]); return; }
|
if (!companyId) return;
|
||||||
Promise.all([
|
Promise.all([
|
||||||
supabase.from('projects').select('id, name').eq('company_id', companyId).order('name'),
|
supabase.from('projects').select('id, name').eq('company_id', companyId).order('name'),
|
||||||
showRequester
|
showRequester
|
||||||
@@ -72,17 +89,53 @@ export default function RequestForm({
|
|||||||
setCompanyUsers(merged);
|
setCompanyUsers(merged);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
setForm(f => ({ ...f, project: '', requestedBy: '' }));
|
}, [companyId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const set = (field) => (e) => {
|
||||||
|
if (localError) setLocalError('');
|
||||||
|
setForm(f => ({ ...f, [field]: e.target.value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const setSign = (id, field, value) => {
|
||||||
|
if (localError) setLocalError('');
|
||||||
|
setSigns(prev => prev.map(s => s.id === id ? { ...s, [field]: value } : s));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sign rows follow the count field; resized on edit rather than in an effect.
|
||||||
|
const handleSignCountChange = (e) => {
|
||||||
|
const raw = e.target.value;
|
||||||
|
setSignCount(raw);
|
||||||
|
const n = Math.max(0, parseInt(raw) || 0);
|
||||||
|
setSigns(prev => {
|
||||||
|
if (n < prev.length) return prev.slice(0, n);
|
||||||
|
if (n > prev.length) {
|
||||||
|
const extra = Array.from({ length: n - prev.length }, () => ({ id: crypto.randomUUID(), signName: '', signFamily: '' }));
|
||||||
|
return [...prev, ...extra];
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleServiceTypeChange = (e) => {
|
||||||
|
set('serviceType')(e);
|
||||||
|
if (e.target.value !== 'Brand Book') { setSignCount(''); setSigns([]); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Switching company invalidates the project/requester picked under the old one.
|
||||||
|
const handleCompanyChange = (e) => {
|
||||||
|
setForm(f => ({ ...f, companyId: e.target.value, project: '', requestedBy: '' }));
|
||||||
setCustomProjects([]);
|
setCustomProjects([]);
|
||||||
setIsTypingProject(false);
|
setIsTypingProject(false);
|
||||||
setNewProjectName('');
|
setNewProjectName('');
|
||||||
}, [companyId]); // eslint-disable-line react-hooks/exhaustive-deps
|
};
|
||||||
|
|
||||||
const set = (field) => (e) => setForm(f => ({ ...f, [field]: e.target.value }));
|
// No company selected yet means nothing loaded applies, so read through as empty.
|
||||||
|
const activeProjects = companyId ? existingProjects : [];
|
||||||
|
const activeCompanyUsers = companyId ? companyUsers : [];
|
||||||
|
|
||||||
const allProjectNames = [
|
const allProjectNames = [
|
||||||
...existingProjects.map(p => p.name),
|
...activeProjects.map(p => p.name),
|
||||||
...customProjects.filter(name => !existingProjects.some(p => p.name === name)),
|
...customProjects.filter(name => !activeProjects.some(p => p.name === name)),
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleProjectSelect = (e) => {
|
const handleProjectSelect = (e) => {
|
||||||
@@ -105,113 +158,203 @@ export default function RequestForm({
|
|||||||
setNewProjectName('');
|
setNewProjectName('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const requesterOptions = showRequester ? [
|
|
||||||
...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)`, email: currentUser.email || '' }] : []),
|
|
||||||
...companyUsers.filter(u => u.id !== currentUser?.id),
|
|
||||||
] : [];
|
|
||||||
|
|
||||||
const showCompanySelect = companies.length > 1 || showRequester;
|
const showCompanySelect = companies.length > 1 || showRequester;
|
||||||
|
const requesterOptions = [
|
||||||
|
...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)` }] : []),
|
||||||
|
...activeCompanyUsers.filter(u => u.id !== currentUser?.id),
|
||||||
|
];
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
const handleSubmit = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const requester = requesterOptions.find(u => u.id === form.requestedBy);
|
setLocalError('');
|
||||||
const requestedByName = requester ? requester.name.replace(' (You)', '') : '';
|
const selectedRequester = showRequester
|
||||||
onSubmit({ ...form, companyId, requestedByName }, files, existingProjects);
|
? 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,
|
||||||
|
requestedByName,
|
||||||
|
signCount: usesSignFields ? normalizedSignCount : null,
|
||||||
|
signs: usesSignFields ? normalizedSigns : [],
|
||||||
|
},
|
||||||
|
files,
|
||||||
|
existingProjects
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
{showCompanySelect && (
|
{/* Row 1: Company + Project */}
|
||||||
<div className="form-group">
|
<div className="grid-2">
|
||||||
<label>Company *</label>
|
{lockedFields.includes('company') ? (
|
||||||
<select
|
<div className="form-group">
|
||||||
value={form.companyId}
|
<label style={modalLabelStyle}>Company</label>
|
||||||
onChange={e => setForm(f => ({ ...f, companyId: e.target.value }))}
|
<input value={companies.find(c => c.id === companyId)?.name || companyId} disabled style={{ ...modalInputStyle, opacity: 0.5, cursor: 'not-allowed' }} />
|
||||||
required
|
|
||||||
>
|
|
||||||
<option value="">Select company...</option>
|
|
||||||
{companies.map(co => <option key={co.id} value={co.id}>{co.name}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Project *</label>
|
|
||||||
{isTypingProject ? (
|
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Enter project name..."
|
|
||||||
value={newProjectName}
|
|
||||||
onChange={e => setNewProjectName(e.target.value)}
|
|
||||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleAddProject(); } }}
|
|
||||||
autoFocus
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
/>
|
|
||||||
<button type="button" className="btn btn-primary btn-sm" onClick={handleAddProject} disabled={!newProjectName.trim()}>Add</button>
|
|
||||||
<button type="button" className="btn btn-outline btn-sm" onClick={() => { setIsTypingProject(false); setNewProjectName(''); }}>Cancel</button>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : showCompanySelect ? (
|
||||||
<select value={form.project} onChange={handleProjectSelect} required disabled={showCompanySelect && !companyId}>
|
<div className="form-group">
|
||||||
<option value="">{showCompanySelect && !companyId ? 'Select company first' : 'Select a project...'}</option>
|
<label style={modalLabelStyle}>Company *</label>
|
||||||
{allProjectNames.map(name => <option key={name} value={name}>{name}</option>)}
|
<select value={companyId} onChange={handleCompanyChange} required style={modalInputStyle}>
|
||||||
{(!showCompanySelect || companyId) && <option value="__new__">+ Create new project...</option>}
|
<option value="">Select company...</option>
|
||||||
</select>
|
{companies.map(co => <option key={co.id} value={co.id}>{co.name}</option>)}
|
||||||
)}
|
</select>
|
||||||
|
</div>
|
||||||
|
) : companies.length === 1 ? (
|
||||||
|
<div className="form-group">
|
||||||
|
<label style={modalLabelStyle}>Company</label>
|
||||||
|
<input value={companies[0].name} disabled style={{ ...modalInputStyle, opacity: 0.5, cursor: 'not-allowed' }} />
|
||||||
|
</div>
|
||||||
|
) : <div />}
|
||||||
|
<div className="form-group">
|
||||||
|
<label style={modalLabelStyle}>Project {!lockedFields.includes('project') && '*'}</label>
|
||||||
|
{lockedFields.includes('project') ? (
|
||||||
|
<input value={form.project} disabled style={{ ...modalInputStyle, opacity: 0.5, cursor: 'not-allowed' }} />
|
||||||
|
) : isTypingProject ? (
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<input type="text" placeholder="Enter project name..." value={newProjectName} onChange={e => setNewProjectName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleAddProject(); } }} autoFocus style={{ ...modalInputStyle, flex: 1 }} />
|
||||||
|
<button type="button" className="btn btn-outline" onClick={handleAddProject} disabled={!newProjectName.trim()}>Add</button>
|
||||||
|
<button type="button" className="btn btn-outline" onClick={() => { setIsTypingProject(false); setNewProjectName(''); }}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<select value={form.project} onChange={handleProjectSelect} required disabled={showCompanySelect && !companyId} style={modalInputStyle}>
|
||||||
|
<option value="">{showCompanySelect && !companyId ? 'Select company first' : 'Select a project...'}</option>
|
||||||
|
{allProjectNames.map(name => <option key={name} value={name}>{name}</option>)}
|
||||||
|
{(!showCompanySelect || companyId) && <option value="__new__">+ Create new project...</option>}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: Service Type + Desired Deadline */}
|
||||||
<div className="grid-2">
|
<div className="grid-2">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Service Type *</label>
|
<label style={modalLabelStyle}>Service Type *</label>
|
||||||
<select value={form.serviceType} onChange={set('serviceType')} required>
|
<select value={form.serviceType} onChange={handleServiceTypeChange} required style={modalInputStyle}>
|
||||||
<option value="">Select service...</option>
|
<option value="">Select service...</option>
|
||||||
{serviceTypes.map(s => <option key={s} value={s}>{s}</option>)}
|
{serviceTypes.map(s => <option key={s} value={s}>{s}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Desired Deadline</label>
|
<label style={modalLabelStyle}>Desired Deadline</label>
|
||||||
<input type="date" value={form.deadline} onChange={set('deadline')} />
|
<input type="date" value={form.deadline} onChange={set('deadline')} style={modalInputStyle} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group" style={{ marginTop: -4 }}>
|
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: 10, fontWeight: 500, cursor: 'pointer' }}>
|
|
||||||
<input type="checkbox" checked={form.isHot} onChange={e => setForm(f => ({ ...f, isHot: e.target.checked }))} />
|
|
||||||
<span>Mark as Hot</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showRequester && (
|
{showRequester && (
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Requested By *</label>
|
<label style={modalLabelStyle}>Requested By *</label>
|
||||||
<select value={form.requestedBy} onChange={set('requestedBy')} disabled={!companyId} required>
|
<select
|
||||||
|
value={form.requestedBy}
|
||||||
|
onChange={set('requestedBy')}
|
||||||
|
required
|
||||||
|
disabled={!companyId}
|
||||||
|
style={modalInputStyle}
|
||||||
|
>
|
||||||
<option value="">{companyId ? 'Select requester...' : 'Select company first'}</option>
|
<option value="">{companyId ? 'Select requester...' : 'Select company first'}</option>
|
||||||
{requesterOptions.map(user => (
|
{requesterOptions.map(user => (
|
||||||
<option key={user.id} value={user.id}>{user.name}{user.email ? ` (${user.email})` : ''}</option>
|
<option key={user.id} value={user.id}>{user.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="form-group">
|
{/* Row 3: Title/Location + Mark as Hot inline */}
|
||||||
<label>Request Title *</label>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 12, alignItems: 'end', marginBottom: 16 }}>
|
||||||
<input type="text" placeholder="e.g. City, State or Site Name" value={form.title} onChange={set('title')} required />
|
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||||
|
<label style={modalLabelStyle}>Project / Location *</label>
|
||||||
|
<input type="text" placeholder="e.g. City, State or Site Name" value={form.title} onChange={set('title')} required style={modalInputStyle} />
|
||||||
|
</div>
|
||||||
|
<label style={{ ...modalLabelStyle, display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', marginBottom: 4, whiteSpace: 'nowrap' }}>
|
||||||
|
<input type="checkbox" checked={form.isHot} onChange={e => setForm(f => ({ ...f, isHot: e.target.checked }))} />
|
||||||
|
<span>Mark as Hot</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{usesSignFields && (
|
||||||
|
<div className="form-group">
|
||||||
|
<label style={modalLabelStyle}>Sign Count *</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="50"
|
||||||
|
placeholder="How many signs?"
|
||||||
|
value={signCount}
|
||||||
|
onChange={handleSignCountChange}
|
||||||
|
required
|
||||||
|
style={{ ...modalInputStyle, width: '100%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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' }}>
|
||||||
|
<div style={{ ...modalLabelStyle, marginBottom: 8 }}>Sign {i + 1}</div>
|
||||||
|
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||||
|
<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)}
|
||||||
|
required
|
||||||
|
style={modalInputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Description *</label>
|
<label style={modalLabelStyle}>{usesSignFields ? 'Notes' : 'Description'} *</label>
|
||||||
<textarea placeholder="Notes on the request..." value={form.description} onChange={set('description')} style={{ minHeight: 100 }} required />
|
<textarea placeholder="Notes on the request..." value={form.description} onChange={set('description')} style={modalTextAreaStyle} required />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FileAttachment files={files} onChange={setFiles} />
|
<FileAttachment files={files} onChange={setFiles} />
|
||||||
|
|
||||||
{error && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>⚠ {error}</div>}
|
{(localError || error) && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>⚠ {localError || error}</div>}
|
||||||
|
|
||||||
<div className="action-buttons">
|
<div className="action-buttons modal-action-row" style={{ justifyContent: 'flex-end' }}>
|
||||||
<button type="submit" className="btn btn-primary" disabled={saving}>
|
<button type="submit" className="btn btn-outline" disabled={saving} style={modalActionButtonStyle}>
|
||||||
{saving ? 'Submitting...' : submitLabel}
|
{saving ? 'Submitting...' : submitLabel}
|
||||||
</button>
|
</button>
|
||||||
{onCancel && <button type="button" className="btn btn-outline" onClick={onCancel}>Cancel</button>}
|
{onCancel && <button type="button" className="btn btn-outline" onClick={onCancel} style={modalActionButtonStyle}>Cancel</button>}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
export default function SortTh({ col, children, sortKey, sortDir, onSort, style }) {
|
export default function SortTh({ col, children, sortKey, sortDir, onSort, style, className }) {
|
||||||
const active = sortKey === col;
|
const active = sortKey === col;
|
||||||
return (
|
return (
|
||||||
<th
|
<th
|
||||||
|
className={className}
|
||||||
onClick={() => onSort(col)}
|
onClick={() => onSort(col)}
|
||||||
style={{ cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap', ...style }}
|
style={{ cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap', ...style }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ const labels = {
|
|||||||
client: 'Client',
|
client: 'Client',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function StatusBadge({ status }) {
|
export default function StatusBadge({ status, label }) {
|
||||||
return (
|
return (
|
||||||
<span className={`badge badge-${status}`}>
|
<span className={`badge badge-status badge-${status}`}>
|
||||||
{labels[status] || status}
|
{label ?? labels[status] ?? status}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = item._inferredType || 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,558 @@
|
|||||||
|
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';
|
||||||
|
import { isReviewShadowDescription } from '../lib/taskVersions';
|
||||||
|
import { useActionLock } from '../hooks/useActionLock';
|
||||||
|
|
||||||
|
const INVOICE_TODAY = new Date().toISOString().split('T')[0];
|
||||||
|
const REVISION_RATE = 30;
|
||||||
|
const ELIGIBLE_TASK_STATUSES = new Set(['not_started', 'in_progress', 'on_hold', 'client_review', 'client_approved', 'invoiced', 'paid']);
|
||||||
|
const FIELD_LABEL_STYLE = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, display: 'block', marginBottom: 4 };
|
||||||
|
const FIELD_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 guard = useActionLock();
|
||||||
|
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: deliveryRows, error: deliveriesError }, { data: existingInvoices, error: invoicesError }, { data: nextNum, error: nextNumError }] = await Promise.all([
|
||||||
|
// Scoped by who actually delivered each version, not by current task assignment —
|
||||||
|
// a task can be reassigned mid-flight and the original sub still needs to bill
|
||||||
|
// for the version(s) they personally delivered.
|
||||||
|
supabase
|
||||||
|
.from('deliveries')
|
||||||
|
.select('version_number, sent_by, submission:submissions!inner(task_id, description, task:tasks!inner(id, title, status, current_version, project:projects(name)))'),
|
||||||
|
supabase
|
||||||
|
.from('subcontractor_invoices')
|
||||||
|
.select('id, status, items:subcontractor_invoice_items(task_id, version_number, description)')
|
||||||
|
.in('status', ['submitted', 'paid']),
|
||||||
|
supabase.rpc('get_next_sub_invoice_number'),
|
||||||
|
]);
|
||||||
|
if (deliveriesError) throw deliveriesError;
|
||||||
|
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;
|
||||||
|
// Stored version preferred; legacy rows (null) fall back to description parsing
|
||||||
|
const version = item.version_number != null ? Number(item.version_number) : parseVersionFromDescription(item.description);
|
||||||
|
return `${item.task_id}:${version}`;
|
||||||
|
}).filter(Boolean))
|
||||||
|
);
|
||||||
|
|
||||||
|
const myName = (currentUser.name || '').trim().toLowerCase();
|
||||||
|
const unitsByKey = new Map();
|
||||||
|
for (const delivery of (deliveryRows || [])) {
|
||||||
|
if ((delivery.sent_by || '').trim().toLowerCase() !== myName) continue;
|
||||||
|
const submission = delivery.submission;
|
||||||
|
const task = submission?.task;
|
||||||
|
if (!task || !ELIGIBLE_TASK_STATUSES.has(task.status)) continue;
|
||||||
|
if (isReviewShadowDescription(submission.description)) continue;
|
||||||
|
const version = Number(delivery.version_number || 0);
|
||||||
|
if (!isCompletedVersionEligible(task, version)) continue;
|
||||||
|
const key = `${task.id}:${version}`;
|
||||||
|
if (invoicedUnitKeys.has(key) || unitsByKey.has(key)) continue;
|
||||||
|
unitsByKey.set(key, {
|
||||||
|
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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const units = [...unitsByKey.values()].sort((a, b) => a.title.localeCompare(b.title) || a.version_number - b.version_number);
|
||||||
|
|
||||||
|
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 = guard('sub-invoice-submit', 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,
|
||||||
|
version_number: item.task_id ? Number(item.version_number) || 0 : 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>
|
||||||
|
) : error ? (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--danger)' }}>{error}</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>
|
||||||
|
) : error ? (
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--danger)' }}>{error}</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -124,6 +124,17 @@ export function AuthProvider({ children }) {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentUser?.id) return;
|
||||||
|
const channel = supabase.channel(`profile-${currentUser.id}`)
|
||||||
|
.on('postgres_changes', { event: 'UPDATE', schema: 'public', table: 'profiles', filter: `id=eq.${currentUser.id}` }, async () => {
|
||||||
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
if (session?.user) fetchAndCacheProfile(session.user);
|
||||||
|
})
|
||||||
|
.subscribe();
|
||||||
|
return () => { supabase.removeChannel(channel); };
|
||||||
|
}, [currentUser?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const login = async (email, password) => {
|
const login = async (email, password) => {
|
||||||
const { error } = await supabase.auth.signInWithPassword({ email, password });
|
const { error } = await supabase.auth.signInWithPassword({ email, password });
|
||||||
if (error) return { error: error.message };
|
if (error) return { error: error.message };
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ export const mockUsers = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const serviceTypes = [
|
export const serviceTypes = [
|
||||||
|
'Brand Book',
|
||||||
|
'Sign Family',
|
||||||
'Logo Design',
|
'Logo Design',
|
||||||
'Brand Identity',
|
'Brand Identity',
|
||||||
'Brand Guidelines',
|
'Brand Guidelines',
|
||||||
'Brand Book',
|
|
||||||
'Social Media Graphics',
|
'Social Media Graphics',
|
||||||
'Print Design',
|
'Print Design',
|
||||||
'Business Cards',
|
'Business Cards',
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { useRef, useCallback } from 'react';
|
||||||
|
|
||||||
|
// Guards async handlers against double-fire (e.g. a rapid double-click that triggers
|
||||||
|
// both onClick callbacks before React re-renders the now-disabled button). Disabled
|
||||||
|
// buttons alone don't prevent this because the disable only takes effect after a render.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// const guard = useActionLock();
|
||||||
|
// const handleSubmit = guard('submit', async () => { ... });
|
||||||
|
//
|
||||||
|
// Each `key` locks independently, so unrelated buttons don't block each other. While a
|
||||||
|
// run is in-flight the same key is a no-op until it settles (resolve or throw).
|
||||||
|
export function useActionLock() {
|
||||||
|
const locks = useRef(new Set());
|
||||||
|
return useCallback((key, fn) => async (...args) => {
|
||||||
|
if (locks.current.has(key)) return undefined;
|
||||||
|
locks.current.add(key);
|
||||||
|
try {
|
||||||
|
return await fn(...args);
|
||||||
|
} finally {
|
||||||
|
locks.current.delete(key);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { supabase } from '../lib/supabase';
|
||||||
|
|
||||||
|
export function useRealtimeSubscription(tables, onchange) {
|
||||||
|
const onchangeRef = useRef(onchange);
|
||||||
|
useEffect(() => { onchangeRef.current = onchange; });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const channelName = `rt-${tables.join('-')}-${Date.now()}`;
|
||||||
|
let channel = supabase.channel(channelName);
|
||||||
|
for (const table of tables) {
|
||||||
|
channel = channel.on('postgres_changes', { event: '*', schema: 'public', table }, () => onchangeRef.current());
|
||||||
|
}
|
||||||
|
channel.subscribe();
|
||||||
|
return () => { supabase.removeChannel(channel); };
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
export function useRefetchOnFocus(refetch) {
|
||||||
|
const refetchRef = useRef(refetch);
|
||||||
|
useEffect(() => { refetchRef.current = refetch; });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onFocus = () => refetchRef.current();
|
||||||
|
window.addEventListener('focus', onFocus);
|
||||||
|
return () => window.removeEventListener('focus', onFocus);
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
@@ -1,13 +1,31 @@
|
|||||||
import { supabase } from './supabase';
|
import { supabase } from './supabase';
|
||||||
|
|
||||||
export async function logActivity({ actorId, actorName, action, taskId, taskTitle, projectId, projectName }) {
|
export async function logActivity({ actorId, actorName, action, taskId, taskTitle, projectId, projectName }) {
|
||||||
|
const duplicateWindowMs = 8000;
|
||||||
|
const duplicateCutoff = new Date(Date.now() - duplicateWindowMs).toISOString();
|
||||||
|
const normalizedActorId = actorId || null;
|
||||||
|
const normalizedTaskId = taskId || null;
|
||||||
|
const normalizedProjectId = projectId || null;
|
||||||
|
|
||||||
|
// Guard against fast duplicate writes from double-clicks/retries.
|
||||||
|
const { data: existing, error: existingError } = await supabase
|
||||||
|
.from('activity_log')
|
||||||
|
.select('id')
|
||||||
|
.eq('action', action)
|
||||||
|
.eq('actor_id', normalizedActorId)
|
||||||
|
.eq('task_id', normalizedTaskId)
|
||||||
|
.eq('project_id', normalizedProjectId)
|
||||||
|
.gte('created_at', duplicateCutoff)
|
||||||
|
.limit(1);
|
||||||
|
if (!existingError && (existing || []).length > 0) return;
|
||||||
|
|
||||||
const { error } = await supabase.from('activity_log').insert({
|
const { error } = await supabase.from('activity_log').insert({
|
||||||
actor_id: actorId || null,
|
actor_id: normalizedActorId,
|
||||||
actor_name: actorName || null,
|
actor_name: actorName || null,
|
||||||
action,
|
action,
|
||||||
task_id: taskId || null,
|
task_id: normalizedTaskId,
|
||||||
task_title: taskTitle || null,
|
task_title: taskTitle || null,
|
||||||
project_id: projectId || null,
|
project_id: normalizedProjectId,
|
||||||
project_name: projectName || null,
|
project_name: projectName || null,
|
||||||
});
|
});
|
||||||
if (error) console.error('logActivity failed:', action, error);
|
if (error) console.error('logActivity failed:', action, error);
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import jsPDF from 'jspdf';
|
import jsPDF from 'jspdf';
|
||||||
|
import { formatLongDate } from './dates';
|
||||||
|
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
return formatLongDate(dateStr ? `${dateStr}T12:00:00` : '', '-');
|
||||||
|
}
|
||||||
|
|
||||||
// Letter landscape: 792 x 612 pt
|
// Letter landscape: 792 x 612 pt
|
||||||
const W = 792;
|
const W = 792;
|
||||||
@@ -8,12 +13,6 @@ const ACCENT = [245, 165, 35];
|
|||||||
const DARK = [18, 18, 18];
|
const DARK = [18, 18, 18];
|
||||||
const HEADER_H = 64;
|
const HEADER_H = 64;
|
||||||
|
|
||||||
function formatDate(dateStr) {
|
|
||||||
if (!dateStr) return '-';
|
|
||||||
const d = new Date(dateStr + 'T00:00:00');
|
|
||||||
return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCoverAddress(address) {
|
function formatCoverAddress(address) {
|
||||||
const parts = String(address || '')
|
const parts = String(address || '')
|
||||||
.split(',')
|
.split(',')
|
||||||
@@ -384,7 +383,7 @@ export async function generateBrandBookEditorPDF(data) {
|
|||||||
let pageNum = 0;
|
let pageNum = 0;
|
||||||
const rev = String(revision || '01').padStart(2, '0');
|
const rev = String(revision || '01').padStart(2, '0');
|
||||||
const displayDate = bookDate
|
const displayDate = bookDate
|
||||||
? new Date(bookDate + 'T12:00:00').toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
|
? formatLongDate(`${bookDate}T12:00:00`, '-')
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
// ─── COVER PAGE ──────────────────────────────────────────────────────────────
|
// ─── COVER PAGE ──────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
export function useLiveClock() {
|
function useLiveClock() {
|
||||||
const [now, setNow] = useState(() => new Date());
|
const [now, setNow] = useState(() => new Date());
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = setInterval(() => setNow(new Date()), 1000);
|
const id = setInterval(() => setNow(new Date()), 1000);
|
||||||
@@ -20,10 +20,3 @@ export function DashboardBanner() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getGreeting() {
|
|
||||||
const h = new Date().getHours();
|
|
||||||
if (h < 12) return 'Good morning';
|
|
||||||
if (h < 17) return 'Good afternoon';
|
|
||||||
return 'Good evening';
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,6 +8,46 @@ export function formatDateEST(value) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatShortDate(value, fallback = '—') {
|
||||||
|
if (!value) return fallback;
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return fallback;
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatLongDate(value, fallback = '—') {
|
||||||
|
if (!value) return fallback;
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return fallback;
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatShortDateTime(value, fallback = '—') {
|
||||||
|
if (!value) return fallback;
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return fallback;
|
||||||
|
return `${formatShortDate(date, fallback)} ${date.toLocaleTimeString('en-US', {
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true,
|
||||||
|
})}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateAtNoon(value, fallback = '—') {
|
||||||
|
if (!value) return fallback;
|
||||||
|
const date = new Date(`${value}T12:00:00`);
|
||||||
|
if (Number.isNaN(date.getTime())) return fallback;
|
||||||
|
return formatLongDate(date, fallback);
|
||||||
|
}
|
||||||
|
|
||||||
export function parseDateOnly(value) {
|
export function parseDateOnly(value) {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
const [year, month, day] = String(value).split('-').map(Number);
|
const [year, month, day] = String(value).split('-').map(Number);
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
import { supabase } from './supabase';
|
|
||||||
|
|
||||||
async function fbCall(method, action, body = null) {
|
|
||||||
const { data: { session } } = await supabase.auth.getSession();
|
|
||||||
if (!session?.access_token) return;
|
|
||||||
|
|
||||||
await fetch(`/api/filebrowser?action=${action}`, {
|
|
||||||
method,
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${session.access_token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create /Clients/{name}/ folder. Silently fails if already exists.
|
|
||||||
export async function createClientFolder(companyName) {
|
|
||||||
if (!companyName) return;
|
|
||||||
await fbCall('POST', 'mkdir', { path: '/', name: 'Clients' });
|
|
||||||
await fbCall('POST', 'mkdir', { path: '/Clients', name: companyName });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rename /Clients/{oldName} → /Clients/{newName}
|
|
||||||
export async function renameClientFolder(oldName, newName) {
|
|
||||||
if (!oldName || !newName || oldName === newName) return;
|
|
||||||
await fbCall('POST', 'rename', { path: `/Clients/${oldName}`, name: newName });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Same safeName logic as the server (api/filebrowser.js)
|
|
||||||
function safeName(v) {
|
|
||||||
return String(v || '').trim()
|
|
||||||
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.replace(/^-+|-+$/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upload files to the correct Request Info/{rev} folder in FileBrowser.
|
|
||||||
// companyName is required. versionNumber defaults to 0 (R00).
|
|
||||||
// Best-effort — call with .catch(() => {}) so failures don't block submission.
|
|
||||||
export async function uploadFilesToRequestInfo(files, companyName, projectName, taskTitle, versionNumber = 0) {
|
|
||||||
if (!files?.length || !companyName || !projectName || !taskTitle) return;
|
|
||||||
|
|
||||||
const { data: { session } } = await supabase.auth.getSession();
|
|
||||||
if (!session?.access_token) return;
|
|
||||||
const authHeader = `Bearer ${session.access_token}`;
|
|
||||||
|
|
||||||
// Determine role
|
|
||||||
const { data: profile } = await supabase.from('profiles').select('role').eq('id', session.user.id).single();
|
|
||||||
const role = profile?.role;
|
|
||||||
|
|
||||||
const co = safeName(companyName);
|
|
||||||
const proj = safeName(projectName);
|
|
||||||
const task = safeName(taskTitle);
|
|
||||||
const rev = `R${String(versionNumber).padStart(2, '0')}`;
|
|
||||||
|
|
||||||
// Build virtual path segments for mkdir.
|
|
||||||
// Clients: virtual root is per-company; company folder already exists — start one level in.
|
|
||||||
// Team/external: full path under /Clients/{co}/...
|
|
||||||
let mkdirs;
|
|
||||||
let revPath;
|
|
||||||
|
|
||||||
if (role === 'client') {
|
|
||||||
revPath = `/${co}/Projects/${proj}/${task}/Request Info/${rev}`;
|
|
||||||
mkdirs = [
|
|
||||||
{ path: `/${co}`, name: 'Projects' },
|
|
||||||
{ path: `/${co}/Projects`, name: proj },
|
|
||||||
{ path: `/${co}/Projects/${proj}`, name: task },
|
|
||||||
{ path: `/${co}/Projects/${proj}/${task}`, name: 'Request Info' },
|
|
||||||
{ path: `/${co}/Projects/${proj}/${task}/Request Info`, name: rev },
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
revPath = `/Clients/${co}/Projects/${proj}/${task}/Request Info/${rev}`;
|
|
||||||
mkdirs = [
|
|
||||||
{ path: '/Clients', name: co },
|
|
||||||
{ path: `/Clients/${co}`, name: 'Projects' },
|
|
||||||
{ path: `/Clients/${co}/Projects`, name: proj },
|
|
||||||
{ path: `/Clients/${co}/Projects/${proj}`, name: task },
|
|
||||||
{ path: `/Clients/${co}/Projects/${proj}/${task}`, name: 'Request Info' },
|
|
||||||
{ path: `/Clients/${co}/Projects/${proj}/${task}/Request Info`, name: rev },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const seg of mkdirs) {
|
|
||||||
await fetch('/api/filebrowser?action=mkdir', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { Authorization: authHeader, 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(seg),
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get upload token for the revision folder
|
|
||||||
const tokenRes = await fetch('/api/filebrowser?action=upload-token', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { Authorization: authHeader, 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ path: revPath }),
|
|
||||||
}).catch(() => null);
|
|
||||||
if (!tokenRes?.ok) return;
|
|
||||||
|
|
||||||
const { token, url, fbPath } = await tokenRes.json();
|
|
||||||
if (!token || !url || !fbPath) return;
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
await fetch(`${url}/api/resources?source=files&path=${encodeURIComponent(`${fbPath}/${file.name}`)}&override=true`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': file.type || 'application/octet-stream' },
|
|
||||||
body: file,
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create missing /Clients/{name}/ folders for all companies.
|
|
||||||
export async function backfillClientFolders() {
|
|
||||||
const { data } = await supabase.from('companies').select('name');
|
|
||||||
if (!data?.length) return;
|
|
||||||
await fbCall('POST', 'mkdir', { path: '/', name: 'Clients' });
|
|
||||||
for (const company of data) {
|
|
||||||
if (company.name) await fbCall('POST', 'mkdir', { path: '/Clients', name: company.name });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -589,159 +589,215 @@ export async function generateReceiptPDF(invoice, company, items, options = {})
|
|||||||
|
|
||||||
export async function generateSubcontractorPOPDF(po, options = {}) {
|
export async function generateSubcontractorPOPDF(po, options = {}) {
|
||||||
const logo = await loadImage('/fourge-logo.png');
|
const logo = await loadImage('/fourge-logo.png');
|
||||||
const doc = new jsPDF({ unit: 'pt', format: 'letter', compress: true, putOnlyUsedFonts: true, precision: 2 });
|
|
||||||
const pageWidth = 612;
|
const pageWidth = 612;
|
||||||
|
const pageHeight = 792;
|
||||||
|
const scale = 3;
|
||||||
const margin = 42;
|
const margin = 42;
|
||||||
const rightEdge = pageWidth - margin;
|
const rightEdge = pageWidth - margin;
|
||||||
const headerH = 62;
|
const headerH = 62;
|
||||||
const poNumber = po.po_number || 'PO';
|
const footerLineY = pageHeight - 44;
|
||||||
|
const footerTextY = pageHeight - 28;
|
||||||
|
const safeBottom = footerLineY - 16;
|
||||||
|
|
||||||
|
const poNumber = po.po_number || 'INV';
|
||||||
const statusLabel = String(po.status || 'draft').replace(/_/g, ' ').toUpperCase();
|
const statusLabel = String(po.status || 'draft').replace(/_/g, ' ').toUpperCase();
|
||||||
const projectName = po.project?.name || 'Subcontractor Work';
|
const projectName = po.project?.name || 'Subcontractor Work';
|
||||||
const companyName = po.project?.company?.name || 'Fourge Branding';
|
const companyName = po.project?.company?.name || 'Fourge Branding';
|
||||||
const subcontractorName = po.profile?.name || 'External Team Member';
|
const subcontractorName = po.profile?.name || 'External Team Member';
|
||||||
const subcontractorEmail = po.profile?.email || '';
|
const subcontractorEmail = po.profile?.email || '';
|
||||||
|
const tableW = pageWidth - margin * 2;
|
||||||
|
const rowH = 28;
|
||||||
|
|
||||||
doc.setFillColor(20, 20, 20);
|
const metaRows = [
|
||||||
doc.rect(0, 0, pageWidth, headerH, 'F');
|
['Invoice Date', po.date ? formatDate(po.date) : '—'],
|
||||||
|
|
||||||
if (logo) {
|
|
||||||
const logoW = 96;
|
|
||||||
const logoH = logoW / (logo.naturalWidth / logo.naturalHeight);
|
|
||||||
doc.addImage(logo, 'PNG', margin, 16, logoW, logoH);
|
|
||||||
} else {
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.setFontSize(13);
|
|
||||||
doc.setTextColor(255, 255, 255);
|
|
||||||
doc.text('FOURGE BRANDING', margin, 36);
|
|
||||||
}
|
|
||||||
|
|
||||||
doc.setFont('helvetica', 'normal');
|
|
||||||
doc.setFontSize(9);
|
|
||||||
doc.setTextColor(170, 170, 170);
|
|
||||||
doc.text('1855.368.7434 | hello@fourgebranding.com | www.fourgebranding.com', rightEdge, 35, { align: 'right' });
|
|
||||||
|
|
||||||
let y = 102;
|
|
||||||
doc.setTextColor(30, 30, 30);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.setFontSize(22);
|
|
||||||
doc.text('PURCHASE ORDER', margin, y);
|
|
||||||
|
|
||||||
doc.setFontSize(10);
|
|
||||||
doc.setTextColor(90, 90, 90);
|
|
||||||
doc.text(poNumber, rightEdge, y - 4, { align: 'right' });
|
|
||||||
y += 24;
|
|
||||||
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.setFontSize(9);
|
|
||||||
doc.setTextColor(120, 120, 120);
|
|
||||||
doc.text('SUBCONTRACTOR', margin, y);
|
|
||||||
doc.text('DETAILS', 330, y);
|
|
||||||
y += 16;
|
|
||||||
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.setFontSize(12);
|
|
||||||
doc.setTextColor(30, 30, 30);
|
|
||||||
doc.text(subcontractorName, margin, y);
|
|
||||||
doc.text(projectName, 330, y);
|
|
||||||
y += 14;
|
|
||||||
|
|
||||||
doc.setFont('helvetica', 'normal');
|
|
||||||
doc.setFontSize(10);
|
|
||||||
doc.setTextColor(95, 95, 95);
|
|
||||||
if (subcontractorEmail) doc.text(subcontractorEmail, margin, y);
|
|
||||||
doc.text(companyName, 330, y);
|
|
||||||
y += 30;
|
|
||||||
|
|
||||||
const rows = [
|
|
||||||
['PO Date', po.date ? formatDate(po.date) : ''],
|
|
||||||
['Due Date', po.due_date ? formatDate(po.due_date) : 'Not set'],
|
['Due Date', po.due_date ? formatDate(po.due_date) : 'Not set'],
|
||||||
['Terms', po.terms || 'Net 15'],
|
['Terms', po.terms || 'Net 15'],
|
||||||
['Status', statusLabel],
|
['Status', statusLabel],
|
||||||
['Amount', formatCurrency(po.amount)],
|
['Amount', formatCurrency(po.amount)],
|
||||||
];
|
];
|
||||||
|
|
||||||
const tableX = margin;
|
const sortedItems = (po.items || [])
|
||||||
const tableW = pageWidth - margin * 2;
|
.slice()
|
||||||
rows.forEach(([label, value], index) => {
|
.sort((a, b) => Number(a.sort_order || 0) - Number(b.sort_order || 0));
|
||||||
const rowY = y + index * 28;
|
|
||||||
if (index % 2 === 0) {
|
// ── Measure and paginate items ──────────────────────────────────────────────
|
||||||
doc.setFillColor(248, 248, 248);
|
const measureCanvas = document.createElement('canvas');
|
||||||
doc.rect(tableX, rowY - 16, tableW, 28, 'F');
|
const mCtx = measureCanvas.getContext('2d');
|
||||||
|
if (!mCtx) throw new Error('Canvas unavailable');
|
||||||
|
|
||||||
|
// First page: header + title block + meta table → items start ~y=310
|
||||||
|
const firstPageItemsStart = headerH + 20 + 26 + 32 + metaRows.length * rowH + 38;
|
||||||
|
const contPageItemsStart = headerH + 50; // after continuation label
|
||||||
|
const firstPageItemSlots = Math.floor((safeBottom - firstPageItemsStart) / rowH);
|
||||||
|
const contPageItemSlots = Math.floor((safeBottom - contPageItemsStart) / rowH);
|
||||||
|
|
||||||
|
const pages = [];
|
||||||
|
let remaining = [...sortedItems];
|
||||||
|
pages.push(remaining.splice(0, Math.max(1, firstPageItemSlots)));
|
||||||
|
while (remaining.length > 0) {
|
||||||
|
pages.push(remaining.splice(0, Math.max(1, contPageItemSlots)));
|
||||||
|
}
|
||||||
|
if (pages.length === 0) pages.push([]);
|
||||||
|
|
||||||
|
// ── Per-page canvas renderer ────────────────────────────────────────────────
|
||||||
|
function renderPageCanvas(pageItems, pageIndex, pageCount) {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = pageWidth * scale;
|
||||||
|
canvas.height = pageHeight * scale;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.scale(scale, scale);
|
||||||
|
|
||||||
|
// White background
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.fillRect(0, 0, pageWidth, pageHeight);
|
||||||
|
|
||||||
|
// ── Header bar ────────────────────────────────────────────────────────────
|
||||||
|
ctx.fillStyle = '#141414';
|
||||||
|
ctx.fillRect(0, 0, pageWidth, headerH);
|
||||||
|
if (logo) {
|
||||||
|
const logoW = 96;
|
||||||
|
const logoH = logoW / (logo.naturalWidth / logo.naturalHeight);
|
||||||
|
ctx.drawImage(logo, margin, (headerH - logoH) / 2, logoW, logoH);
|
||||||
|
} else {
|
||||||
|
ctx.font = '700 13px Helvetica, Arial, sans-serif';
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.fillText('FOURGE BRANDING', margin, headerH / 2 + 4);
|
||||||
}
|
}
|
||||||
doc.setFont('helvetica', 'normal');
|
ctx.font = '9px Helvetica, Arial, sans-serif';
|
||||||
doc.setFontSize(10);
|
ctx.fillStyle = '#aaaaaa';
|
||||||
doc.setTextColor(105, 105, 105);
|
drawRightText(ctx, '1855.368.7434 | hello@fourgebranding.com | www.fourgebranding.com', rightEdge, headerH / 2 + 3);
|
||||||
doc.text(label, tableX + 12, rowY);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.setTextColor(35, 35, 35);
|
|
||||||
doc.text(String(value), tableX + tableW - 12, rowY, { align: 'right' });
|
|
||||||
});
|
|
||||||
y += rows.length * 28 + 22;
|
|
||||||
|
|
||||||
if (po.items?.length > 0) {
|
// ── Footer (every page) ───────────────────────────────────────────────────
|
||||||
doc.setFont('helvetica', 'bold');
|
drawRule(ctx, margin, rightEdge, footerLineY);
|
||||||
doc.setFontSize(11);
|
ctx.font = '8px Helvetica, Arial, sans-serif';
|
||||||
doc.setTextColor(30, 30, 30);
|
ctx.fillStyle = '#aaaaaa';
|
||||||
doc.text('Line Items', margin, y);
|
mCtx.font = '8px Helvetica, Arial, sans-serif';
|
||||||
y += 16;
|
const footerLines = wrapText(mCtx,
|
||||||
|
'This invoice authorizes payment for the subcontractor work described above. Payment is subject to Fourge Branding approval and completion of assigned work.',
|
||||||
|
rightEdge - margin);
|
||||||
|
footerLines.forEach((line, i) => ctx.fillText(line, margin, footerTextY + i * 10));
|
||||||
|
|
||||||
const sortedItems = po.items
|
let y = headerH;
|
||||||
.slice()
|
|
||||||
.sort((a, b) => Number(a.sort_order || 0) - Number(b.sort_order || 0));
|
|
||||||
|
|
||||||
sortedItems.forEach((item, index) => {
|
if (pageIndex === 0) {
|
||||||
const rowTop = y + index * 28;
|
// ── Title ───────────────────────────────────────────────────────────────
|
||||||
if (index % 2 === 0) {
|
y += 26;
|
||||||
doc.setFillColor(248, 248, 248);
|
ctx.font = '700 22px Helvetica, Arial, sans-serif';
|
||||||
doc.rect(tableX, rowTop - 14, tableW, 28, 'F');
|
ctx.fillStyle = '#1e1e1e';
|
||||||
|
ctx.fillText('SUBCONTRACTOR INVOICE', margin, y);
|
||||||
|
ctx.font = '10px Helvetica, Arial, sans-serif';
|
||||||
|
ctx.fillStyle = '#5a5a5a';
|
||||||
|
drawRightText(ctx, poNumber, rightEdge, y - 4);
|
||||||
|
y += 26;
|
||||||
|
|
||||||
|
// ── Two-column info ──────────────────────────────────────────────────────
|
||||||
|
ctx.font = '700 9px Helvetica, Arial, sans-serif';
|
||||||
|
ctx.fillStyle = '#787878';
|
||||||
|
ctx.fillText('SUBCONTRACTOR', margin, y);
|
||||||
|
ctx.fillText('DETAILS', 330, y);
|
||||||
|
y += 16;
|
||||||
|
|
||||||
|
ctx.font = '700 12px Helvetica, Arial, sans-serif';
|
||||||
|
ctx.fillStyle = '#1e1e1e';
|
||||||
|
ctx.fillText(subcontractorName, margin, y);
|
||||||
|
ctx.fillText(projectName, 330, y);
|
||||||
|
y += 14;
|
||||||
|
|
||||||
|
ctx.font = '10px Helvetica, Arial, sans-serif';
|
||||||
|
ctx.fillStyle = '#5f5f5f';
|
||||||
|
if (subcontractorEmail) ctx.fillText(subcontractorEmail, margin, y);
|
||||||
|
ctx.fillText(companyName, 330, y);
|
||||||
|
y += 30;
|
||||||
|
|
||||||
|
// ── Meta table ───────────────────────────────────────────────────────────
|
||||||
|
metaRows.forEach(([label, value], idx) => {
|
||||||
|
const rowY = y + idx * rowH;
|
||||||
|
if (idx % 2 === 0) {
|
||||||
|
ctx.fillStyle = '#f8f8f8';
|
||||||
|
ctx.fillRect(margin, rowY - 16, tableW, rowH);
|
||||||
|
}
|
||||||
|
ctx.font = '10px Helvetica, Arial, sans-serif';
|
||||||
|
ctx.fillStyle = '#696969';
|
||||||
|
ctx.fillText(label, margin + 12, rowY);
|
||||||
|
ctx.font = '700 10px Helvetica, Arial, sans-serif';
|
||||||
|
ctx.fillStyle = '#232323';
|
||||||
|
mCtx.font = '700 10px Helvetica, Arial, sans-serif';
|
||||||
|
drawRightText(ctx, String(value), margin + tableW - 12, rowY);
|
||||||
|
});
|
||||||
|
y += metaRows.length * rowH + 22;
|
||||||
|
} else {
|
||||||
|
// ── Continuation label ───────────────────────────────────────────────────
|
||||||
|
y += 28;
|
||||||
|
ctx.font = '9px Helvetica, Arial, sans-serif';
|
||||||
|
ctx.fillStyle = '#888888';
|
||||||
|
ctx.fillText(`${poNumber} (continued)`, margin, y);
|
||||||
|
y += 22;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Line items ─────────────────────────────────────────────────────────────
|
||||||
|
if (pageItems.length > 0 || pageIndex === 0) {
|
||||||
|
ctx.font = '700 11px Helvetica, Arial, sans-serif';
|
||||||
|
ctx.fillStyle = '#1e1e1e';
|
||||||
|
ctx.fillText('Line Items', margin, y);
|
||||||
|
y += 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
pageItems.forEach((item, idx) => {
|
||||||
|
if (idx % 2 === 0) {
|
||||||
|
ctx.fillStyle = '#f8f8f8';
|
||||||
|
ctx.fillRect(margin, y - 14, tableW, rowH);
|
||||||
}
|
}
|
||||||
doc.setFont('helvetica', 'normal');
|
ctx.font = '10px Helvetica, Arial, sans-serif';
|
||||||
doc.setFontSize(10);
|
ctx.fillStyle = '#2d2d2d';
|
||||||
doc.setTextColor(45, 45, 45);
|
mCtx.font = '10px Helvetica, Arial, sans-serif';
|
||||||
doc.text(item.description || item.task?.title || 'Work Item', tableX + 12, rowTop);
|
const descLines = wrapText(mCtx, item.description || item.task?.title || 'Work Item', tableW - 120);
|
||||||
doc.setFont('helvetica', 'bold');
|
ctx.fillText(descLines[0], margin + 12, y);
|
||||||
doc.text(formatCurrency(item.amount), tableX + tableW - 12, rowTop, { align: 'right' });
|
ctx.font = '700 10px Helvetica, Arial, sans-serif';
|
||||||
|
ctx.fillStyle = '#1e1e1e';
|
||||||
|
drawRightText(ctx, formatCurrency(item.amount), margin + tableW - 12, y);
|
||||||
|
y += rowH;
|
||||||
});
|
});
|
||||||
|
|
||||||
y += sortedItems.length * 28 + 22;
|
// ── Last page: summary + notes ─────────────────────────────────────────────
|
||||||
|
if (pageIndex === pageCount - 1) {
|
||||||
|
y += 10;
|
||||||
|
if (po.description) {
|
||||||
|
ctx.font = '700 11px Helvetica, Arial, sans-serif';
|
||||||
|
ctx.fillStyle = '#1e1e1e';
|
||||||
|
ctx.fillText(po.items?.length > 0 ? 'Summary' : 'Scope of Work', margin, y);
|
||||||
|
y += 16;
|
||||||
|
ctx.font = '10px Helvetica, Arial, sans-serif';
|
||||||
|
ctx.fillStyle = '#464646';
|
||||||
|
mCtx.font = '10px Helvetica, Arial, sans-serif';
|
||||||
|
const scopeLines = wrapText(mCtx, po.description, tableW);
|
||||||
|
scopeLines.forEach(line => { ctx.fillText(line, margin, y); y += 13; });
|
||||||
|
y += 10;
|
||||||
|
}
|
||||||
|
if (po.notes) {
|
||||||
|
ctx.font = '700 11px Helvetica, Arial, sans-serif';
|
||||||
|
ctx.fillStyle = '#1e1e1e';
|
||||||
|
ctx.fillText('Notes', margin, y);
|
||||||
|
y += 16;
|
||||||
|
ctx.font = '10px Helvetica, Arial, sans-serif';
|
||||||
|
ctx.fillStyle = '#464646';
|
||||||
|
mCtx.font = '10px Helvetica, Arial, sans-serif';
|
||||||
|
const noteLines = wrapText(mCtx, po.notes, tableW);
|
||||||
|
noteLines.forEach(line => { ctx.fillText(line, margin, y); y += 13; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return canvas;
|
||||||
}
|
}
|
||||||
|
|
||||||
doc.setFont('helvetica', 'bold');
|
// ── Assemble PDF ────────────────────────────────────────────────────────────
|
||||||
doc.setFontSize(11);
|
const doc = new jsPDF({ unit: 'pt', format: 'letter', compress: true, putOnlyUsedFonts: true, precision: 2 });
|
||||||
doc.setTextColor(30, 30, 30);
|
for (let i = 0; i < pages.length; i++) {
|
||||||
doc.text(po.items?.length > 0 ? 'Summary' : 'Scope of Work', margin, y);
|
if (i > 0) doc.addPage();
|
||||||
y += 16;
|
const canvas = renderPageCanvas(pages[i], i, pages.length);
|
||||||
|
const imgData = canvas.toDataURL('image/jpeg', 0.97);
|
||||||
doc.setFont('helvetica', 'normal');
|
doc.addImage(imgData, 'JPEG', 0, 0, pageWidth, pageHeight);
|
||||||
doc.setFontSize(10);
|
|
||||||
doc.setTextColor(70, 70, 70);
|
|
||||||
const scopeLines = doc.splitTextToSize(po.description || '', pageWidth - margin * 2);
|
|
||||||
doc.text(scopeLines, margin, y);
|
|
||||||
y += scopeLines.length * 13 + 18;
|
|
||||||
|
|
||||||
if (po.notes) {
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.setTextColor(30, 30, 30);
|
|
||||||
doc.text('Notes', margin, y);
|
|
||||||
y += 16;
|
|
||||||
doc.setFont('helvetica', 'normal');
|
|
||||||
doc.setTextColor(70, 70, 70);
|
|
||||||
const noteLines = doc.splitTextToSize(po.notes, pageWidth - margin * 2);
|
|
||||||
doc.text(noteLines, margin, y);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
doc.setDrawColor(220, 220, 220);
|
|
||||||
doc.line(margin, 704, rightEdge, 704);
|
|
||||||
doc.setFont('helvetica', 'normal');
|
|
||||||
doc.setFontSize(9);
|
|
||||||
doc.setTextColor(120, 120, 120);
|
|
||||||
doc.text(
|
|
||||||
'This purchase order authorizes the subcontractor work described above. Payment is subject to Fourge Branding approval and completion of assigned work.',
|
|
||||||
margin,
|
|
||||||
724,
|
|
||||||
{ maxWidth: pageWidth - margin * 2 },
|
|
||||||
);
|
|
||||||
|
|
||||||
const filename = `${poNumber}.pdf`;
|
const filename = `${poNumber}.pdf`;
|
||||||
if (options.save === false || options.output === 'blob') return doc.output('blob');
|
if (options.save === false || options.output === 'blob') return doc.output('blob');
|
||||||
doc.save(filename);
|
doc.save(filename);
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
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, revisionType) {
|
||||||
|
// Fourge's own error revisions are never billed to the client.
|
||||||
|
if (revisionType === 'fourge_error') return 0;
|
||||||
|
const version = Number(versionNumber || 0);
|
||||||
|
return version >= 2 ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parses version number from a subcontractor invoice item description like
|
||||||
|
// "Project • Task – R01". Legacy fallback only — new rows store version_number directly.
|
||||||
|
export function parseVersionFromItemDescription(description = '') {
|
||||||
|
const match = String(description).match(/[–-]\s*R(\d{2})\b/i);
|
||||||
|
return match ? Number(match[1]) : 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
export const popupOverlayStyle = {
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 1200,
|
||||||
|
background: 'var(--overlay-scrim)',
|
||||||
|
backdropFilter: 'blur(6px)',
|
||||||
|
WebkitBackdropFilter: 'blur(6px)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 24,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const popupSurfaceStyle = {
|
||||||
|
background: 'var(--popup-bg)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '18px 21px',
|
||||||
|
backdropFilter: 'blur(12px)',
|
||||||
|
WebkitBackdropFilter: 'blur(12px)',
|
||||||
|
boxShadow: 'var(--popup-shadow)',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POPUP_FIELD_LABEL = {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.8,
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const popupMenuStyle = {
|
||||||
|
background: 'var(--popup-bg)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 8,
|
||||||
|
backdropFilter: 'blur(12px)',
|
||||||
|
WebkitBackdropFilter: 'blur(12px)',
|
||||||
|
boxShadow: 'var(--popup-shadow)',
|
||||||
|
};
|
||||||
@@ -1,9 +1,18 @@
|
|||||||
import { supabase } from './supabase';
|
import { supabase } from './supabase';
|
||||||
|
import { ensureProjectFolder } from './folderSync';
|
||||||
|
|
||||||
function normalizeProjectName(name = '') {
|
function normalizeProjectName(name = '') {
|
||||||
return String(name).trim().toLowerCase();
|
return String(name).trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function serializeSubmissionSigns(normalizedSigns = []) {
|
||||||
|
return normalizedSigns.map((s, i) => ({
|
||||||
|
sign_number: i + 1,
|
||||||
|
sign_name: s.signName,
|
||||||
|
sign_family: s.signFamily || null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
export async function findOrCreateProject(companyId, projectName, knownProjects = []) {
|
export async function findOrCreateProject(companyId, projectName, knownProjects = []) {
|
||||||
const normalized = normalizeProjectName(projectName);
|
const normalized = normalizeProjectName(projectName);
|
||||||
if (!companyId || !normalized) throw new Error('Project company and name are required.');
|
if (!companyId || !normalized) throw new Error('Project company and name are required.');
|
||||||
@@ -31,7 +40,14 @@ export async function findOrCreateProject(companyId, projectName, knownProjects
|
|||||||
.select('id, name, company_id, status')
|
.select('id, name, company_id, status')
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (!insertError && newProject) return newProject;
|
if (!insertError && newProject) {
|
||||||
|
try {
|
||||||
|
await ensureProjectFolder({ projectId: newProject.id });
|
||||||
|
} catch (folderError) {
|
||||||
|
console.error('Project folder sync failed:', folderError);
|
||||||
|
}
|
||||||
|
return newProject;
|
||||||
|
}
|
||||||
|
|
||||||
if (insertError?.code === '23505') {
|
if (insertError?.code === '23505') {
|
||||||
const { data: retryProjects, error: retryError } = await supabase
|
const { data: retryProjects, error: retryError } = await supabase
|
||||||
@@ -80,11 +96,28 @@ export async function createInitialSubmissionForRequest({
|
|||||||
requestKey,
|
requestKey,
|
||||||
isHot,
|
isHot,
|
||||||
serviceType,
|
serviceType,
|
||||||
|
signFamily,
|
||||||
|
signCount,
|
||||||
|
signs,
|
||||||
deadline,
|
deadline,
|
||||||
description,
|
description,
|
||||||
submittedBy,
|
submittedBy,
|
||||||
submittedByName,
|
submittedByName,
|
||||||
}) {
|
}) {
|
||||||
|
const normalizedSignCount = Number.isFinite(Number(signCount)) ? Number(signCount) : null;
|
||||||
|
const normalizedSigns = Array.isArray(signs)
|
||||||
|
? signs
|
||||||
|
.map((sign = {}) => ({
|
||||||
|
signName: String(sign.signName || '').trim(),
|
||||||
|
signFamily: String(sign.signFamily || '').trim(),
|
||||||
|
}))
|
||||||
|
.filter(sign => sign.signName || sign.signFamily)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if ((normalizedSignCount || 0) > 0 && normalizedSigns.length !== normalizedSignCount) {
|
||||||
|
throw new Error('Please fill in the sign information for each sign before submitting.');
|
||||||
|
}
|
||||||
|
|
||||||
const { data: submission, error } = await supabase
|
const { data: submission, error } = await supabase
|
||||||
.from('submissions')
|
.from('submissions')
|
||||||
.insert({
|
.insert({
|
||||||
@@ -94,6 +127,9 @@ export async function createInitialSubmissionForRequest({
|
|||||||
type: 'initial',
|
type: 'initial',
|
||||||
is_hot: isHot,
|
is_hot: isHot,
|
||||||
service_type: serviceType,
|
service_type: serviceType,
|
||||||
|
sign_family: signFamily || null,
|
||||||
|
sign_count: normalizedSignCount || null,
|
||||||
|
signs: serializeSubmissionSigns(normalizedSigns),
|
||||||
deadline: deadline || null,
|
deadline: deadline || null,
|
||||||
description,
|
description,
|
||||||
submitted_by: submittedBy,
|
submitted_by: submittedBy,
|
||||||
@@ -111,7 +147,17 @@ export async function createInitialSubmissionForRequest({
|
|||||||
.eq('request_key', requestKey)
|
.eq('request_key', requestKey)
|
||||||
.single();
|
.single();
|
||||||
if (existingError) throw existingError;
|
if (existingError) throw existingError;
|
||||||
if (existingSubmission) return { submission: existingSubmission, duplicate: true };
|
if (existingSubmission) {
|
||||||
|
const existingSigns = Array.isArray(existingSubmission.signs) ? existingSubmission.signs : [];
|
||||||
|
if (normalizedSigns.length > 0 && existingSigns.length === 0) {
|
||||||
|
const { error: repairError } = await supabase
|
||||||
|
.from('submissions')
|
||||||
|
.update({ signs: serializeSubmissionSigns(normalizedSigns) })
|
||||||
|
.eq('id', existingSubmission.id);
|
||||||
|
if (repairError) throw repairError;
|
||||||
|
}
|
||||||
|
return { submission: existingSubmission, duplicate: true };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw error || new Error('Failed to create submission.');
|
throw error || new Error('Failed to create submission.');
|
||||||
|
|||||||
@@ -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 jsPDF from 'jspdf';
|
||||||
|
import { formatDateAtNoon } from './dates';
|
||||||
|
|
||||||
const W = 792;
|
const W = 792;
|
||||||
const H = 612;
|
const H = 612;
|
||||||
@@ -8,9 +9,7 @@ const DARK = [18, 18, 18];
|
|||||||
const HEADER_H = 64;
|
const HEADER_H = 64;
|
||||||
|
|
||||||
function formatDate(dateStr) {
|
function formatDate(dateStr) {
|
||||||
if (!dateStr) return '-';
|
return formatDateAtNoon(dateStr, '-');
|
||||||
const d = new Date(`${dateStr}T12:00:00`);
|
|
||||||
return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function blobToDataUrl(blob) {
|
async function blobToDataUrl(blob) {
|
||||||
|
|||||||
@@ -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,103 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Picks the representative service_type for a task's initial work for pricing.
|
||||||
|
// Skips review-shadow rows and blank service_types (those break price lookups),
|
||||||
|
// preferring a real initial submission, then any submission with a service_type.
|
||||||
|
export function pickInitialServiceType(submissions = [], fallback = '') {
|
||||||
|
const visible = getVisibleTaskSubmissions(submissions);
|
||||||
|
const initialWithSvc = visible.find(s => s?.type === 'initial' && s?.service_type);
|
||||||
|
if (initialWithSvc?.service_type) return initialWithSvc.service_type;
|
||||||
|
const anyInitial = visible.find(s => s?.type === 'initial');
|
||||||
|
if (anyInitial?.service_type) return anyInitial.service_type;
|
||||||
|
const anyWithSvc = visible.find(s => s?.service_type);
|
||||||
|
return anyWithSvc?.service_type || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { useSortable } from '../hooks/useSortable';
|
|||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import { generateBrandBookEditorPDF } from '../lib/brandBookEditor';
|
import { generateBrandBookEditorPDF } from '../lib/brandBookEditor';
|
||||||
import { cleanupBrandBookStorage } from '../lib/deleteHelpers';
|
import { cleanupBrandBookStorage } from '../lib/deleteHelpers';
|
||||||
|
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
|
||||||
|
|
||||||
const BUCKET = 'brand-books';
|
const BUCKET = 'brand-books';
|
||||||
|
|
||||||
@@ -158,7 +159,6 @@ export default function BrandBook() {
|
|||||||
const projectLogoRef = useRef();
|
const projectLogoRef = useRef();
|
||||||
const clientLogoRef = useRef();
|
const clientLogoRef = useRef();
|
||||||
const [filterCompany, setFilterCompany] = useState('');
|
const [filterCompany, setFilterCompany] = useState('');
|
||||||
const [selectedBookIds, setSelectedBookIds] = useState([]);
|
|
||||||
const { sortKey: bbSortKey, sortDir: bbSortDir, toggle: bbToggle, sort: bbSort } = useSortable('updated_at');
|
const { sortKey: bbSortKey, sortDir: bbSortDir, toggle: bbToggle, sort: bbSort } = useSortable('updated_at');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -174,7 +174,8 @@ export default function BrandBook() {
|
|||||||
|
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
if (address.includes(',') && address.length >= 12) {
|
if (address.includes(',') && address.length >= 12) {
|
||||||
loadMapsFromAddress(address, { silent: true });
|
// Called through a ref so the debounce isn't reset by the handler's identity.
|
||||||
|
loadMapsFromAddressRef.current(address, { silent: true });
|
||||||
}
|
}
|
||||||
}, 900);
|
}, 900);
|
||||||
|
|
||||||
@@ -221,7 +222,6 @@ export default function BrandBook() {
|
|||||||
setLoadingBooks(true);
|
setLoadingBooks(true);
|
||||||
const { data } = await supabase.from('brand_books').select('*').order('updated_at', { ascending: false });
|
const { data } = await supabase.from('brand_books').select('*').order('updated_at', { ascending: false });
|
||||||
setSavedBooks(data || []);
|
setSavedBooks(data || []);
|
||||||
setSelectedBookIds(prev => prev.filter(id => (data || []).some(book => book.id === id)));
|
|
||||||
setLoadingBooks(false);
|
setLoadingBooks(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -234,6 +234,8 @@ export default function BrandBook() {
|
|||||||
setBookInfo(b => ({ ...b, revision: normalizeRevision(b.revision) }));
|
setBookInfo(b => ({ ...b, revision: normalizeRevision(b.revision) }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadMapsFromAddressRef = useRef(null);
|
||||||
|
|
||||||
const loadMapsFromAddress = async (address = bookInfo.customerAddress, { silent = false, feature = null } = {}) => {
|
const loadMapsFromAddress = async (address = bookInfo.customerAddress, { silent = false, feature = null } = {}) => {
|
||||||
const trimmed = String(address || '').trim();
|
const trimmed = String(address || '').trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
@@ -289,6 +291,10 @@ export default function BrandBook() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadMapsFromAddressRef.current = loadMapsFromAddress;
|
||||||
|
});
|
||||||
|
|
||||||
const handleCustomerAddressChange = (e) => {
|
const handleCustomerAddressChange = (e) => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
setBookInfo(b => ({ ...b, customerAddress: value, siteAddress: value }));
|
setBookInfo(b => ({ ...b, customerAddress: value, siteAddress: value }));
|
||||||
@@ -451,7 +457,7 @@ export default function BrandBook() {
|
|||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!bookInfo.clientName.trim()) {
|
if (!bookInfo.clientName.trim()) {
|
||||||
setNotification({ type: 'error', msg: 'Please select a client.' });
|
setNotification({ type: 'error', msg: 'Please select a company.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -613,7 +619,7 @@ export default function BrandBook() {
|
|||||||
|
|
||||||
const handleGenerate = async () => {
|
const handleGenerate = async () => {
|
||||||
if (!bookInfo.clientName.trim()) {
|
if (!bookInfo.clientName.trim()) {
|
||||||
setNotification({ type: 'error', msg: 'Please select a client.' });
|
setNotification({ type: 'error', msg: 'Please select a company.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setGenerating(true);
|
setGenerating(true);
|
||||||
@@ -681,7 +687,7 @@ export default function BrandBook() {
|
|||||||
const handleClientLogoUpload = async (e) => {
|
const handleClientLogoUpload = async (e) => {
|
||||||
const file = e.target.files[0];
|
const file = e.target.files[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
if (!bookInfo.clientId) { setNotification({ type: 'error', msg: 'Select a client first.' }); return; }
|
if (!bookInfo.clientId) { setNotification({ type: 'error', msg: 'Select a company first.' }); return; }
|
||||||
setUploadingClientLogo(true);
|
setUploadingClientLogo(true);
|
||||||
const ext = file.name.split('.').pop().toLowerCase();
|
const ext = file.name.split('.').pop().toLowerCase();
|
||||||
const path = `${bookInfo.clientId}/logo.${ext}`;
|
const path = `${bookInfo.clientId}/logo.${ext}`;
|
||||||
@@ -792,20 +798,21 @@ export default function BrandBook() {
|
|||||||
{loadingBooks ? (
|
{loadingBooks ? (
|
||||||
<p style={{ padding: '24px 0', color: 'var(--text-muted)' }}>Loading...</p>
|
<p style={{ padding: '24px 0', color: 'var(--text-muted)' }}>Loading...</p>
|
||||||
) : filteredBooks.length === 0 ? (
|
) : filteredBooks.length === 0 ? (
|
||||||
<div className="empty-state">
|
<div className="card" style={{ minHeight: 160, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12 }}>
|
||||||
<h3>{savedBooks.length === 0 ? 'No brand books yet' : 'No matching brand books'}</h3>
|
<div style={{ fontSize: 13, color: 'var(--text-muted)', textAlign: 'center' }}>
|
||||||
<p>{savedBooks.length === 0 ? 'Create your first brand book to get started.' : 'Try clearing the current company filter.'}</p>
|
{savedBooks.length === 0 ? 'No brand books' : 'No matching brand books'}
|
||||||
<button className="btn btn-primary" onClick={handleNew} style={{ marginTop: 16 }}>+ New Brand Book</button>
|
</div>
|
||||||
|
{savedBooks.length === 0 && <button className="btn btn-primary" onClick={handleNew}>+ New Brand Book</button>}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="table-wrapper">
|
<div className="table-wrapper">
|
||||||
<table>
|
<table className="table-sticky-head">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<SortTh col="project_name" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Name</SortTh>
|
<SortTh col="project_name" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Name</SortTh>
|
||||||
<SortTh col="revision" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Revision</SortTh>
|
<SortTh col="revision" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Revision</SortTh>
|
||||||
<SortTh col="sign_count" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Sign Count</SortTh>
|
<SortTh col="sign_count" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Sign Count</SortTh>
|
||||||
<SortTh col="client" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Client</SortTh>
|
<SortTh col="client" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Company</SortTh>
|
||||||
<SortTh col="updated_at" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Updated</SortTh>
|
<SortTh col="updated_at" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Updated</SortTh>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -877,7 +884,7 @@ export default function BrandBook() {
|
|||||||
<LoadingButton className="btn btn-primary btn-sm" loading={generating} loadingText="Generating..." onClick={handleGenerate}>Generate PDF</LoadingButton>
|
<LoadingButton className="btn btn-primary btn-sm" loading={generating} loadingText="Generating..." onClick={handleGenerate}>Generate PDF</LoadingButton>
|
||||||
</div>
|
</div>
|
||||||
{notification && (
|
{notification && (
|
||||||
<span style={{ fontSize: 13, color: notification.type === 'error' ? 'var(--danger, #dc2626)' : 'var(--success, #16a34a)' }}>
|
<span style={{ fontSize: 13, color: notification.type === 'error' ? 'var(--danger, var(--danger-strong))' : 'var(--success, var(--success-strong))' }}>
|
||||||
{notification.msg}
|
{notification.msg}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -891,7 +898,7 @@ export default function BrandBook() {
|
|||||||
<div className="card-title">Brand Book Info</div>
|
<div className="card-title">Brand Book Info</div>
|
||||||
<div className="grid-2">
|
<div className="grid-2">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Client *</label>
|
<label>Company *</label>
|
||||||
<select value={bookInfo.clientId} onChange={handleClientChange}>
|
<select value={bookInfo.clientId} onChange={handleClientChange}>
|
||||||
<option value="">— Select client —</option>
|
<option value="">— Select client —</option>
|
||||||
{clients.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
{clients.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||||
@@ -1046,7 +1053,7 @@ export default function BrandBook() {
|
|||||||
{/* Client info (saved per company) */}
|
{/* Client info (saved per company) */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 13, fontWeight: 400, color: 'var(--text-primary)' }}>Client Info</div>
|
<div style={{ fontSize: 13, fontWeight: 400, color: 'var(--text-primary)' }}>Company Info</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>Logo and contact saved to company — reused across all brand books.</div>
|
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>Logo and contact saved to company — reused across all brand books.</div>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-outline btn-sm" onClick={handleSaveClientInfo} disabled={savingClientInfo || !bookInfo.clientId}>
|
<button className="btn btn-outline btn-sm" onClick={handleSaveClientInfo} disabled={savingClientInfo || !bookInfo.clientId}>
|
||||||
@@ -1055,7 +1062,7 @@ export default function BrandBook() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Client Logo <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(3.5"×1.5" area, bottom right)</span></label>
|
<label>Company Logo <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(3.5"×1.5" area, bottom right)</span></label>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
{bookInfo.clientLogoUrl && (
|
{bookInfo.clientLogoUrl && (
|
||||||
<img src={bookInfo.clientLogoUrl} alt="Client logo" style={{ maxHeight: 44, maxWidth: 130, objectFit: 'contain', border: '1px solid var(--border)', borderRadius: 4, padding: 4, background: '#fff' }} />
|
<img src={bookInfo.clientLogoUrl} alt="Client logo" style={{ maxHeight: 44, maxWidth: 130, objectFit: 'contain', border: '1px solid var(--border)', borderRadius: 4, padding: 4, background: '#fff' }} />
|
||||||
@@ -1180,7 +1187,7 @@ export default function BrandBook() {
|
|||||||
<LoadingButton className="btn btn-primary btn-sm" loading={generating} loadingText="Generating..." onClick={handleGenerate}>Generate PDF</LoadingButton>
|
<LoadingButton className="btn btn-primary btn-sm" loading={generating} loadingText="Generating..." onClick={handleGenerate}>Generate PDF</LoadingButton>
|
||||||
</div>
|
</div>
|
||||||
{notification && (
|
{notification && (
|
||||||
<span style={{ fontSize: 13, color: notification.type === 'error' ? 'var(--danger, #dc2626)' : 'var(--success, #16a34a)' }}>
|
<span style={{ fontSize: 13, color: notification.type === 'error' ? 'var(--danger, var(--danger-strong))' : 'var(--success, var(--success-strong))' }}>
|
||||||
{notification.msg}
|
{notification.msg}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -1228,7 +1235,7 @@ function SignCard({ sign, index, onChange, onPhotoChange, onRemove, canRemove, t
|
|||||||
{sign.photo && <span style={{ fontSize: 11, color: 'var(--accent)' }}>unsaved photo</span>}
|
{sign.photo && <span style={{ fontSize: 11, color: 'var(--accent)' }}>unsaved photo</span>}
|
||||||
{canRemove && (
|
{canRemove && (
|
||||||
<span role="button" onClick={onRemove}
|
<span role="button" onClick={onRemove}
|
||||||
style={{ fontSize: 13, color: 'var(--danger, #dc2626)', padding: '2px 6px', cursor: 'pointer' }}>✕</span>
|
style={{ fontSize: 13, color: 'var(--danger, var(--danger-strong))', padding: '2px 6px', cursor: 'pointer' }}>✕</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1334,7 +1341,7 @@ function PhotoField({ label, preview, fileName, dragging, inputRef, onDragEnter,
|
|||||||
style={{
|
style={{
|
||||||
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
|
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))',
|
background: dragging ? 'color-mix(in srgb, var(--accent) 5%, transparent)' : 'var(--input-bg, var(--card-bg))',
|
||||||
padding: 12,
|
padding: 12,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -1405,7 +1412,7 @@ function SiteMapDropZone({ preview, onFile, onClear, inputRef }) {
|
|||||||
style={{
|
style={{
|
||||||
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
|
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))',
|
background: dragging ? 'color-mix(in srgb, var(--accent) 5%, transparent)' : 'var(--input-bg, var(--card-bg))',
|
||||||
padding: '24px 16px', textAlign: 'center', cursor: 'pointer',
|
padding: '24px 16px', textAlign: 'center', cursor: 'pointer',
|
||||||
color: dragging ? 'var(--accent)' : 'var(--text-muted)', fontSize: 13, transition: 'all 0.15s',
|
color: dragging ? 'var(--accent)' : 'var(--text-muted)', fontSize: 13, transition: 'all 0.15s',
|
||||||
}}
|
}}
|
||||||
@@ -1436,7 +1443,7 @@ function SitePhotosDropZone({ photoItems, onFiles, onRemove, inputRef }) {
|
|||||||
style={{
|
style={{
|
||||||
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
|
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))',
|
background: dragging ? 'color-mix(in srgb, var(--accent) 5%, transparent)' : 'var(--input-bg, var(--card-bg))',
|
||||||
padding: '20px 16px', textAlign: 'center', cursor: 'pointer',
|
padding: '20px 16px', textAlign: 'center', cursor: 'pointer',
|
||||||
color: dragging ? 'var(--accent)' : 'var(--text-muted)', fontSize: 13,
|
color: dragging ? 'var(--accent)' : 'var(--text-muted)', fontSize: 13,
|
||||||
marginBottom: photoItems.length > 0 ? 12 : 0, transition: 'all 0.15s',
|
marginBottom: photoItems.length > 0 ? 12 : 0, transition: 'all 0.15s',
|
||||||
@@ -1456,14 +1463,14 @@ function SitePhotosDropZone({ photoItems, onFiles, onRemove, inputRef }) {
|
|||||||
style={{ width: 80, height: 60, objectFit: 'cover', borderRadius: 4, border: '1px solid var(--border)', display: 'block' }}
|
style={{ width: 80, height: 60, objectFit: 'cover', borderRadius: 4, border: '1px solid var(--border)', display: 'block' }}
|
||||||
/>
|
/>
|
||||||
{item.file && (
|
{item.file && (
|
||||||
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, background: 'rgba(245,165,35,0.8)', fontSize: 8, textAlign: 'center', borderRadius: '0 0 4px 4px', padding: '1px 2px', color: '#1a1a1a', fontWeight: 400 }}>NEW</div>
|
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, background: 'color-mix(in srgb, var(--accent) 80%, transparent)', fontSize: 8, textAlign: 'center', borderRadius: '0 0 4px 4px', padding: '1px 2px', color: '#1a1a1a', fontWeight: 400 }}>NEW</div>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onRemove(i)}
|
onClick={() => onRemove(i)}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute', top: -6, right: -6,
|
position: 'absolute', top: -6, right: -6,
|
||||||
background: '#dc2626', border: 'none', borderRadius: '50%',
|
background: 'var(--danger-strong)', border: 'none', borderRadius: '50%',
|
||||||
width: 18, height: 18, fontSize: 10, color: '#fff',
|
width: 18, height: 18, fontSize: 10, color: '#fff',
|
||||||
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', lineHeight: 1,
|
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', lineHeight: 1,
|
||||||
}}
|
}}
|
||||||
@@ -1508,7 +1515,7 @@ function CombinedMockupPhotoField({
|
|||||||
const tileStyle = {
|
const tileStyle = {
|
||||||
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
|
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))',
|
background: dragging ? 'color-mix(in srgb, var(--accent) 5%, transparent)' : 'var(--input-bg, var(--card-bg))',
|
||||||
padding: 12,
|
padding: 12,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -1647,7 +1654,7 @@ function RecommendationPhotoField({ preview, fileName, dragging, inputRef, onDra
|
|||||||
style={{
|
style={{
|
||||||
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
|
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))',
|
background: dragging ? 'color-mix(in srgb, var(--accent) 5%, transparent)' : 'var(--input-bg, var(--card-bg))',
|
||||||
padding: 12,
|
padding: 12,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -1730,7 +1737,7 @@ function SignDetailPhotoField({ preview, fileName, dragging, inputRef, onDragEnt
|
|||||||
style={{
|
style={{
|
||||||
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
|
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))',
|
background: dragging ? 'color-mix(in srgb, var(--accent) 5%, transparent)' : 'var(--input-bg, var(--card-bg))',
|
||||||
padding: 12,
|
padding: 12,
|
||||||
cursor: preview ? 'pointer' : 'default',
|
cursor: preview ? 'pointer' : 'default',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -2350,14 +2357,9 @@ function DimensionEditorModal({ sourceImage, onApply, onCancel }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{ ...popupOverlayStyle, zIndex: 9999, padding: 20 }}
|
||||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.72)',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
zIndex: 9999, padding: 20,
|
|
||||||
}}
|
|
||||||
onClick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
|
|
||||||
>
|
>
|
||||||
<div style={{ background: 'var(--card-bg)', borderRadius: 4, display: 'flex', flexDirection: 'column', maxWidth: '98vw', maxHeight: '96vh', overflow: 'hidden', boxShadow: '0 24px 64px rgba(0,0,0,0.55)' }}>
|
<div style={{ ...popupSurfaceStyle, borderRadius: 8, display: 'flex', flexDirection: 'column', maxWidth: '98vw', maxHeight: '96vh', overflow: 'hidden', padding: 0 }}>
|
||||||
<div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
<div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 14, fontWeight: 400, color: 'var(--text-primary)' }}>Sign Detail Dimensions</div>
|
<div style={{ fontSize: 14, fontWeight: 400, color: 'var(--text-primary)' }}>Sign Detail Dimensions</div>
|
||||||
@@ -2422,7 +2424,7 @@ function DimensionEditorModal({ sourceImage, onApply, onCancel }) {
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ overflow: 'auto', flex: 1, background: '#2a2a2a', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', position: 'relative' }}>
|
<div style={{ overflow: 'auto', flex: 1, background: 'var(--card-bg-2)', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', position: 'relative' }}>
|
||||||
{!loaded && <div style={{ padding: 48, color: '#888', fontSize: 13 }}>Loading image…</div>}
|
{!loaded && <div style={{ padding: 48, color: '#888', fontSize: 13 }}>Loading image…</div>}
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
@@ -2835,7 +2837,7 @@ function PhotoEditorModal({
|
|||||||
ctx.restore();
|
ctx.restore();
|
||||||
if (showGuides && item.id === selectedArtworkId) {
|
if (showGuides && item.id === selectedArtworkId) {
|
||||||
const rotateHandle = getRotateHandle(item);
|
const rotateHandle = getRotateHandle(item);
|
||||||
ctx.strokeStyle = '#f5a523';
|
ctx.strokeStyle = 'var(--accent)';
|
||||||
ctx.lineWidth = 2;
|
ctx.lineWidth = 2;
|
||||||
ctx.setLineDash([6, 4]);
|
ctx.setLineDash([6, 4]);
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
@@ -2853,7 +2855,7 @@ function PhotoEditorModal({
|
|||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
getArtworkHandles(item).forEach(({ x, y }) => {
|
getArtworkHandles(item).forEach(({ x, y }) => {
|
||||||
ctx.fillStyle = '#ffffff';
|
ctx.fillStyle = '#ffffff';
|
||||||
ctx.strokeStyle = '#f5a523';
|
ctx.strokeStyle = 'var(--accent)';
|
||||||
ctx.lineWidth = 2;
|
ctx.lineWidth = 2;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.rect(x - HANDLE_SIZE / 2, y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE);
|
ctx.rect(x - HANDLE_SIZE / 2, y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE);
|
||||||
@@ -2861,13 +2863,13 @@ function PhotoEditorModal({
|
|||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
});
|
});
|
||||||
ctx.fillStyle = '#1a1a1a';
|
ctx.fillStyle = '#1a1a1a';
|
||||||
ctx.strokeStyle = '#f5a523';
|
ctx.strokeStyle = 'var(--accent)';
|
||||||
ctx.lineWidth = 2;
|
ctx.lineWidth = 2;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(rotateHandle.x, rotateHandle.y, HANDLE_SIZE / 1.5, 0, Math.PI * 2);
|
ctx.arc(rotateHandle.x, rotateHandle.y, HANDLE_SIZE / 1.5, 0, Math.PI * 2);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
ctx.fillStyle = '#f5a523';
|
ctx.fillStyle = 'var(--accent)';
|
||||||
ctx.font = '700 10px Helvetica, Arial, sans-serif';
|
ctx.font = '700 10px Helvetica, Arial, sans-serif';
|
||||||
ctx.fillText('↻', rotateHandle.x - 4, rotateHandle.y + 4);
|
ctx.fillText('↻', rotateHandle.x - 4, rotateHandle.y + 4);
|
||||||
}
|
}
|
||||||
@@ -2897,14 +2899,14 @@ function PhotoEditorModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (showGuides && calibrationPoints.length > 0) {
|
if (showGuides && calibrationPoints.length > 0) {
|
||||||
ctx.fillStyle = '#22c55e';
|
ctx.fillStyle = 'var(--success)';
|
||||||
calibrationPoints.forEach((point) => {
|
calibrationPoints.forEach((point) => {
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(point.x, point.y, 5, 0, Math.PI * 2);
|
ctx.arc(point.x, point.y, 5, 0, Math.PI * 2);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
});
|
});
|
||||||
if (calibrationPoints.length === 2) {
|
if (calibrationPoints.length === 2) {
|
||||||
ctx.strokeStyle = '#22c55e';
|
ctx.strokeStyle = 'var(--success)';
|
||||||
ctx.lineWidth = 2;
|
ctx.lineWidth = 2;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(calibrationPoints[0].x, calibrationPoints[0].y);
|
ctx.moveTo(calibrationPoints[0].x, calibrationPoints[0].y);
|
||||||
@@ -3437,18 +3439,9 @@ function PhotoEditorModal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{ ...popupOverlayStyle, zIndex: 9999, padding: 20 }}
|
||||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.72)',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
zIndex: 9999, padding: 20,
|
|
||||||
}}
|
|
||||||
onClick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
|
|
||||||
>
|
>
|
||||||
<div style={{
|
<div style={{ ...popupSurfaceStyle, borderRadius: 8, display: 'flex', flexDirection: 'column', maxWidth: '98vw', maxHeight: '96vh', overflow: 'hidden', padding: 0 }}>
|
||||||
background: 'var(--card-bg)', borderRadius: 4, display: 'flex', flexDirection: 'column',
|
|
||||||
maxWidth: '98vw', maxHeight: '96vh', overflow: 'hidden',
|
|
||||||
boxShadow: '0 24px 64px rgba(0,0,0,0.55)',
|
|
||||||
}}>
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
<div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||||
<div>
|
<div>
|
||||||
@@ -3614,7 +3607,7 @@ function PhotoEditorModal({
|
|||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
border: `1px solid ${item.id === activeDimensionId ? 'var(--accent)' : 'var(--border)'}`,
|
border: `1px solid ${item.id === activeDimensionId ? 'var(--accent)' : 'var(--border)'}`,
|
||||||
background: item.id === activeDimensionId ? 'rgba(245,165,35,0.12)' : 'var(--card-bg-2)',
|
background: item.id === activeDimensionId ? 'color-mix(in srgb, var(--accent) 12%, transparent)' : 'var(--card-bg-2)',
|
||||||
color: 'var(--text-primary)',
|
color: 'var(--text-primary)',
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
padding: '8px 10px',
|
padding: '8px 10px',
|
||||||
@@ -3658,7 +3651,7 @@ function PhotoEditorModal({
|
|||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
border: `1px solid ${item.id === selectedArtworkId ? 'var(--accent)' : 'var(--border)'}`,
|
border: `1px solid ${item.id === selectedArtworkId ? 'var(--accent)' : 'var(--border)'}`,
|
||||||
background: item.id === selectedArtworkId ? 'rgba(245,165,35,0.12)' : 'var(--card-bg-2)',
|
background: item.id === selectedArtworkId ? 'color-mix(in srgb, var(--accent) 12%, transparent)' : 'var(--card-bg-2)',
|
||||||
color: 'var(--text-primary)',
|
color: 'var(--text-primary)',
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
padding: '8px 10px',
|
padding: '8px 10px',
|
||||||
@@ -3685,7 +3678,7 @@ function PhotoEditorModal({
|
|||||||
<div
|
<div
|
||||||
onDragOver={e => e.preventDefault()}
|
onDragOver={e => e.preventDefault()}
|
||||||
onDrop={handleArtworkDrop}
|
onDrop={handleArtworkDrop}
|
||||||
style={{ overflow: 'auto', background: '#2a2a2a', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', position: 'relative', minHeight: 420 }}
|
style={{ overflow: 'auto', background: 'var(--card-bg-2)', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', position: 'relative', minHeight: 420 }}
|
||||||
>
|
>
|
||||||
{!loaded && (
|
{!loaded && (
|
||||||
<div style={{ padding: 48, color: '#888', fontSize: 13 }}>Loading image…</div>
|
<div style={{ padding: 48, color: '#888', fontSize: 13 }}>Loading image…</div>
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
|
import PageLoader from '../components/PageLoader';
|
||||||
import SortTh from '../components/SortTh';
|
import SortTh from '../components/SortTh';
|
||||||
import { useSortable } from '../hooks/useSortable';
|
import { useSortable } from '../hooks/useSortable';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { deleteCompanyData } from '../lib/deleteHelpers';
|
import { deleteCompanyData } from '../lib/deleteHelpers';
|
||||||
import { readPageCache, writePageCache } from '../lib/pageCache';
|
import { readPageCache, writePageCache } from '../lib/pageCache';
|
||||||
import { createClientFolder, backfillClientFolders } from '../lib/filebrowserFolders';
|
import { ensureCompanyFolder, ensureSubcontractorFolder, renameSubcontractorFolder } from '../lib/folderSync';
|
||||||
|
|
||||||
// ── Team view ─────────────────────────────────────────────────────────────────
|
// ── Team view ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -31,7 +32,6 @@ function TeamCompanies() {
|
|||||||
const [editingUserId, setEditingUserId] = useState(null);
|
const [editingUserId, setEditingUserId] = useState(null);
|
||||||
const [editUserVal, setEditUserVal] = useState('');
|
const [editUserVal, setEditUserVal] = useState('');
|
||||||
const [deletingUserId, setDeletingUserId] = useState(null);
|
const [deletingUserId, setDeletingUserId] = useState(null);
|
||||||
const [filterCompany, setFilterCompany] = useState('');
|
|
||||||
const [userSubTab, setUserSubTab] = useState(profileRole === 'external' ? 'external' : 'client');
|
const [userSubTab, setUserSubTab] = useState(profileRole === 'external' ? 'external' : 'client');
|
||||||
const { sortKey: coSortKey, sortDir: coSortDir, toggle: coToggle, sort: coSort } = useSortable('name');
|
const { sortKey: coSortKey, sortDir: coSortDir, toggle: coToggle, sort: coSort } = useSortable('name');
|
||||||
const { sortKey: clSortKey, sortDir: clSortDir, toggle: clToggle, sort: clSort } = useSortable('name');
|
const { sortKey: clSortKey, sortDir: clSortDir, toggle: clToggle, sort: clSort } = useSortable('name');
|
||||||
@@ -50,6 +50,9 @@ function TeamCompanies() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// load() only setStates after awaiting its queries; the rule can't see past the
|
||||||
|
// call boundary, and load() is shared with the create/delete handlers below.
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
useEffect(() => { load(); }, []);
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
const handleCreate = async (e) => {
|
const handleCreate = async (e) => {
|
||||||
@@ -63,8 +66,11 @@ function TeamCompanies() {
|
|||||||
}).select().single();
|
}).select().single();
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
if (data) {
|
if (data) {
|
||||||
createClientFolder(data.name).catch(() => {});
|
try {
|
||||||
backfillClientFolders().catch(() => {});
|
await ensureCompanyFolder({ companyId: data.id });
|
||||||
|
} catch (folderError) {
|
||||||
|
console.error('Company folder sync failed:', folderError);
|
||||||
|
}
|
||||||
setShowNew(false);
|
setShowNew(false);
|
||||||
setNewForm({ name: '', phone: '', address: '' });
|
setNewForm({ name: '', phone: '', address: '' });
|
||||||
navigate(`/company/${data.id}`);
|
navigate(`/company/${data.id}`);
|
||||||
@@ -72,7 +78,7 @@ function TeamCompanies() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteCompany = async (company) => {
|
const handleDeleteCompany = async (company) => {
|
||||||
if (!window.confirm(`Delete "${company.name}"? This will permanently delete all projects, jobs, files, and data for this company. This cannot be undone.`)) return;
|
if (!window.confirm(`Delete "${company.name}"? This will permanently delete all clients, jobs, files, and data for this company. This cannot be undone.`)) return;
|
||||||
await deleteCompanyData(company.id);
|
await deleteCompanyData(company.id);
|
||||||
setCompanies(prev => prev.filter(c => c.id !== company.id));
|
setCompanies(prev => prev.filter(c => c.id !== company.id));
|
||||||
load();
|
load();
|
||||||
@@ -80,8 +86,18 @@ function TeamCompanies() {
|
|||||||
|
|
||||||
const handleEditUserSave = async (userId) => {
|
const handleEditUserSave = async (userId) => {
|
||||||
if (!editUserVal.trim()) return;
|
if (!editUserVal.trim()) return;
|
||||||
await supabase.from('profiles').update({ name: editUserVal.trim() }).eq('id', userId);
|
const existingUser = profiles.find(u => u.id === userId);
|
||||||
setProfiles(prev => prev.map(u => u.id === userId ? { ...u, name: editUserVal.trim() } : u));
|
const oldName = existingUser?.name || '';
|
||||||
|
const newName = editUserVal.trim();
|
||||||
|
await supabase.from('profiles').update({ name: newName }).eq('id', userId);
|
||||||
|
if (existingUser?.role === 'external') {
|
||||||
|
try {
|
||||||
|
await renameSubcontractorFolder({ profileId: userId, oldName, newName });
|
||||||
|
} catch (folderError) {
|
||||||
|
console.error('Subcontractor folder rename failed:', folderError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setProfiles(prev => prev.map(u => u.id === userId ? { ...u, name: newName } : u));
|
||||||
setEditingUserId(null);
|
setEditingUserId(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -113,12 +129,29 @@ function TeamCompanies() {
|
|||||||
const errBody = error?.context ? await error.context.json().catch(() => null) : null;
|
const errBody = error?.context ? await error.context.json().catch(() => null) : null;
|
||||||
const errMsg = errBody?.error || data?.error || error?.message;
|
const errMsg = errBody?.error || data?.error || error?.message;
|
||||||
if (errMsg) { setUserError(errMsg); return; }
|
if (errMsg) { setUserError(errMsg); return; }
|
||||||
|
if (userForm.role === 'external') {
|
||||||
|
try {
|
||||||
|
const { data: newProfile } = await supabase
|
||||||
|
.from('profiles')
|
||||||
|
.select('id')
|
||||||
|
.eq('email', userForm.email.trim())
|
||||||
|
.eq('role', 'external')
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle();
|
||||||
|
if (newProfile?.id) {
|
||||||
|
await ensureSubcontractorFolder({ profileId: newProfile.id });
|
||||||
|
}
|
||||||
|
} catch (folderError) {
|
||||||
|
console.error('Subcontractor folder sync failed:', folderError);
|
||||||
|
}
|
||||||
|
}
|
||||||
setShowNewUser(false);
|
setShowNewUser(false);
|
||||||
setUserForm({ name: '', email: '', password: '', company_id: '', role: 'client' });
|
setUserForm({ name: '', email: '', password: '', company_id: '', role: 'client' });
|
||||||
load();
|
load();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
if (loading) return <Layout><PageLoader /></Layout>;
|
||||||
|
|
||||||
const getProfileCompanyIds = (profile) => {
|
const getProfileCompanyIds = (profile) => {
|
||||||
const ids = new Set(
|
const ids = new Set(
|
||||||
@@ -158,7 +191,7 @@ function TeamCompanies() {
|
|||||||
</div>
|
</div>
|
||||||
{tab === 'companies' && (
|
{tab === 'companies' && (
|
||||||
<button className="btn btn-primary btn-sm" onClick={() => { setShowNew(v => !v); setShowNewUser(false); }}>
|
<button className="btn btn-primary btn-sm" onClick={() => { setShowNew(v => !v); setShowNewUser(false); }}>
|
||||||
{showNew ? 'Cancel' : '+ New Client'}
|
{showNew ? 'Cancel' : '+ New Company'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{tab === 'users' && (
|
{tab === 'users' && (
|
||||||
@@ -197,7 +230,7 @@ function TeamCompanies() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="action-buttons">
|
<div className="action-buttons">
|
||||||
<button type="submit" className="btn btn-primary" disabled={saving || !newForm.name.trim()}>
|
<button type="submit" className="btn btn-primary" disabled={saving || !newForm.name.trim()}>
|
||||||
{saving ? 'Creating...' : 'Create Client'}
|
{saving ? 'Creating...' : 'Create Company'}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn btn-outline" onClick={() => setShowNew(false)}>Cancel</button>
|
<button type="button" className="btn btn-outline" onClick={() => setShowNew(false)}>Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -206,10 +239,10 @@ function TeamCompanies() {
|
|||||||
)}
|
)}
|
||||||
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
{companies.length === 0 ? (
|
{companies.length === 0 ? (
|
||||||
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '8px 0' }}>No clients yet.</div>
|
<div className="card-empty-center">No companies</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||||
<table>
|
<table className="table-sticky-head">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<SortTh col="name" sortKey={coSortKey} sortDir={coSortDir} onSort={coToggle}>Company</SortTh>
|
<SortTh col="name" sortKey={coSortKey} sortDir={coSortDir} onSort={coToggle}>Company</SortTh>
|
||||||
@@ -325,11 +358,11 @@ function TeamCompanies() {
|
|||||||
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
{userSubTab === 'client' && <>
|
{userSubTab === 'client' && <>
|
||||||
{unassigned.length > 0 && (
|
{unassigned.length > 0 && (
|
||||||
<div style={{ marginBottom: 12, padding: 14, background: 'rgba(220,38,38,0.06)', borderRadius: 4, border: '1px solid var(--danger)', flexShrink: 0 }}>
|
<div style={{ marginBottom: 12, padding: 14, background: 'color-mix(in srgb, var(--danger-strong) 6%, transparent)', borderRadius: 4, border: '1px solid var(--danger)', flexShrink: 0 }}>
|
||||||
<div style={{ fontSize: 12, fontWeight: 400, color: 'var(--danger)', marginBottom: 8 }}>Unassigned ({unassigned.length})</div>
|
<div style={{ fontSize: 12, fontWeight: 400, color: 'var(--danger)', marginBottom: 8 }}>Unassigned ({unassigned.length})</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
{unassigned.map(user => (
|
{unassigned.map(user => (
|
||||||
<div key={user.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
|
<div key={user.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--card-bg)', borderRadius: 4, border: 'var(--card-border)' }}>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
{editingUserId === user.id ? (
|
{editingUserId === user.id ? (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
@@ -360,10 +393,10 @@ function TeamCompanies() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{clientProfiles.length === 0 ? (
|
{clientProfiles.length === 0 ? (
|
||||||
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '8px 0' }}>No users yet.</div>
|
<div className="card-empty-center">No users</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||||
<table>
|
<table className="table-sticky-head">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<SortTh col="name" sortKey={clSortKey} sortDir={clSortDir} onSort={clToggle}>Name</SortTh>
|
<SortTh col="name" sortKey={clSortKey} sortDir={clSortDir} onSort={clToggle}>Name</SortTh>
|
||||||
@@ -379,7 +412,7 @@ function TeamCompanies() {
|
|||||||
}).map(user => {
|
}).map(user => {
|
||||||
const companyNames = getProfileCompanyIds(user).map(id => companies.find(c => c.id === id)?.name).filter(Boolean);
|
const companyNames = getProfileCompanyIds(user).map(id => companies.find(c => c.id === id)?.name).filter(Boolean);
|
||||||
return (
|
return (
|
||||||
<tr key={user.id} style={user.id === profileId ? { background: 'rgba(245,165,35,0.08)' } : undefined}>
|
<tr key={user.id} style={user.id === profileId ? { background: 'color-mix(in srgb, var(--accent) 8%, transparent)' } : undefined}>
|
||||||
<td style={{ fontWeight: 400 }}>
|
<td style={{ fontWeight: 400 }}>
|
||||||
{editingUserId === user.id ? (
|
{editingUserId === user.id ? (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
@@ -414,10 +447,10 @@ function TeamCompanies() {
|
|||||||
|
|
||||||
{userSubTab === 'external' && <>
|
{userSubTab === 'external' && <>
|
||||||
{subcontractors.length === 0 ? (
|
{subcontractors.length === 0 ? (
|
||||||
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '8px 0' }}>No subcontractors yet.</div>
|
<div className="card-empty-center">No subcontractors</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||||
<table>
|
<table className="table-sticky-head">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<SortTh col="name" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle}>Name</SortTh>
|
<SortTh col="name" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle}>Name</SortTh>
|
||||||
@@ -427,7 +460,7 @@ function TeamCompanies() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{subSort(subcontractors, (u, key) => u[key] || '').map(user => (
|
{subSort(subcontractors, (u, key) => u[key] || '').map(user => (
|
||||||
<tr key={user.id} style={user.id === profileId ? { background: 'rgba(245,165,35,0.08)' } : undefined}>
|
<tr key={user.id} style={user.id === profileId ? { background: 'color-mix(in srgb, var(--accent) 8%, transparent)' } : undefined}>
|
||||||
<td style={{ fontWeight: 400 }}>
|
<td style={{ fontWeight: 400 }}>
|
||||||
{editingUserId === user.id ? (
|
{editingUserId === user.id ? (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
@@ -481,10 +514,10 @@ function ClientCompanyList() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
{companies.length === 0 ? (
|
{companies.length === 0 ? (
|
||||||
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '8px 0' }}>No companies linked to your account.</div>
|
<div className="card-empty-center">No companies</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||||
<table>
|
<table className="table-sticky-head">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<SortTh col="name" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Company</SortTh>
|
<SortTh col="name" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Company</SortTh>
|
||||||
@@ -509,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 ? (
|
|
||||||
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>No members found.</p>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
|
||||||
{members.map((member, i) => (
|
|
||||||
<div key={member.id} style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 12, padding: '12px 0',
|
|
||||||
borderBottom: i < members.length - 1 ? '1px solid var(--border)' : 'none',
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
width: 36, height: 36, borderRadius: 4, background: 'var(--accent)',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
fontSize: 13, fontWeight: 400, color: '#111', flexShrink: 0,
|
|
||||||
}}>
|
|
||||||
{member.name?.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontWeight: 400, fontSize: 14, color: 'var(--text-primary)' }}>
|
|
||||||
{member.name}
|
|
||||||
{member.id === currentUser.id && (
|
|
||||||
<span style={{ marginLeft: 8, fontSize: 11, color: 'var(--accent)', fontWeight: 500 }}>You</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{member.email || '—'}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Entry point ───────────────────────────────────────────────────────────────
|
// ── Entry point ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function CompaniesPage() {
|
export default function CompaniesPage() {
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
|
import PageLoader from '../components/PageLoader';
|
||||||
import StatusBadge from '../components/StatusBadge';
|
import StatusBadge from '../components/StatusBadge';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { serviceTypes } from '../data/mockData';
|
import { serviceTypes } from '../data/mockData';
|
||||||
import { cleanupTaskStorage, deleteCompanyData } from '../lib/deleteHelpers';
|
import { deleteCompanyData } from '../lib/deleteHelpers';
|
||||||
import { renameClientFolder, backfillClientFolders } from '../lib/filebrowserFolders';
|
|
||||||
import { logActivity } from '../lib/activityLog';
|
import { logActivity } from '../lib/activityLog';
|
||||||
|
import { ensureProjectFolder, renameCompanyFolder } from '../lib/folderSync';
|
||||||
|
|
||||||
export default function CompanyDetail() {
|
export default function CompanyDetail() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
@@ -21,6 +22,8 @@ export default function CompanyDetail() {
|
|||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
const [availableUsers, setAvailableUsers] = useState([]);
|
const [availableUsers, setAvailableUsers] = useState([]);
|
||||||
const [prices, setPrices] = useState([]);
|
const [prices, setPrices] = useState([]);
|
||||||
|
const [signFamilies, setSignFamilies] = useState([]);
|
||||||
|
const [savingSignFamilyPrice, setSavingSignFamilyPrice] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [tab, setTab] = useState('users');
|
const [tab, setTab] = useState('users');
|
||||||
const [savingPrice, setSavingPrice] = useState(null);
|
const [savingPrice, setSavingPrice] = useState(null);
|
||||||
@@ -35,6 +38,10 @@ export default function CompanyDetail() {
|
|||||||
const [editUserVal, setEditUserVal] = useState('');
|
const [editUserVal, setEditUserVal] = useState('');
|
||||||
const [deletingUserId, setDeletingUserId] = useState(null);
|
const [deletingUserId, setDeletingUserId] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
supabase.from('sign_families').select('name').order('sort_order').then(({ data }) => setSignFamilies((data || []).map(r => r.name)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const [{ data: co }, { data: p }, { data: pr }, { data: memberRows }, { data: allUsers }, { data: t }] = await Promise.all([
|
const [{ data: co }, { data: p }, { data: pr }, { data: memberRows }, { data: allUsers }, { data: t }] = await Promise.all([
|
||||||
supabase.from('companies').select('*').eq('id', id).single(),
|
supabase.from('companies').select('*').eq('id', id).single(),
|
||||||
@@ -63,7 +70,6 @@ export default function CompanyDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
load();
|
load();
|
||||||
}, [id]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
@@ -72,10 +78,14 @@ export default function CompanyDetail() {
|
|||||||
if (!nameVal.trim()) return;
|
if (!nameVal.trim()) return;
|
||||||
setSavingName(true);
|
setSavingName(true);
|
||||||
const oldName = company.name;
|
const oldName = company.name;
|
||||||
await supabase.from('companies').update({ name: nameVal.trim() }).eq('id', id);
|
const newName = nameVal.trim();
|
||||||
renameClientFolder(oldName, nameVal.trim()).catch(() => {});
|
await supabase.from('companies').update({ name: newName }).eq('id', id);
|
||||||
backfillClientFolders().catch(() => {});
|
try {
|
||||||
setCompany(c => ({ ...c, name: nameVal.trim() }));
|
await renameCompanyFolder({ companyId: id, oldName, newName });
|
||||||
|
} catch (folderError) {
|
||||||
|
console.error('Company folder rename failed:', folderError);
|
||||||
|
}
|
||||||
|
setCompany(c => ({ ...c, name: newName }));
|
||||||
setEditingName(false);
|
setEditingName(false);
|
||||||
setSavingName(false);
|
setSavingName(false);
|
||||||
};
|
};
|
||||||
@@ -175,20 +185,15 @@ export default function CompanyDetail() {
|
|||||||
status: 'active',
|
status: 'active',
|
||||||
}).select().single();
|
}).select().single();
|
||||||
if (data) {
|
if (data) {
|
||||||
|
try {
|
||||||
|
await ensureProjectFolder({ projectId: data.id });
|
||||||
|
} catch (folderError) {
|
||||||
|
console.error('Project folder sync failed:', folderError);
|
||||||
|
}
|
||||||
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'project_created', projectId: data.id, projectName: data.name });
|
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'project_created', projectId: data.id, projectName: data.name });
|
||||||
setProjects(prev => [data, ...prev]);
|
setProjects(prev => [data, ...prev]);
|
||||||
setNewProjectName('');
|
setNewProjectName('');
|
||||||
setShowNewProject(false);
|
setShowNewProject(false);
|
||||||
// Fire-and-forget: create project folder in FileBrowser
|
|
||||||
supabase.auth.getSession().then(({ data: { session } }) => {
|
|
||||||
if (session?.access_token && company?.name) {
|
|
||||||
fetch('/api/sync-project-folder', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.access_token}` },
|
|
||||||
body: JSON.stringify({ type: 'INSERT', record: { name: data.name, company_name: company.name } }),
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
setSavingProject(false);
|
setSavingProject(false);
|
||||||
};
|
};
|
||||||
@@ -223,7 +228,37 @@ export default function CompanyDetail() {
|
|||||||
setSavingPrice(null);
|
setSavingPrice(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
const getSignFamilyPrice = (signFamily, priceType) =>
|
||||||
|
prices.find(p => p.sign_family === signFamily && p.price_type === priceType && !p.service_type)?.price ?? '';
|
||||||
|
|
||||||
|
const handleSignFamilyPriceChange = (signFamily, priceType, value) => {
|
||||||
|
setPrices(prev => {
|
||||||
|
const existing = prev.find(p => p.sign_family === signFamily && p.price_type === priceType && !p.service_type);
|
||||||
|
if (existing) return prev.map(p => p.sign_family === signFamily && p.price_type === priceType && !p.service_type ? { ...p, price: value } : p);
|
||||||
|
return [...prev, { sign_family: signFamily, price_type: priceType, price: value, company_id: id }];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSignFamilyPriceSave = async (signFamily) => {
|
||||||
|
setSavingSignFamilyPrice(signFamily);
|
||||||
|
for (const priceType of ['new', 'revision']) {
|
||||||
|
const priceVal = getSignFamilyPrice(signFamily, priceType);
|
||||||
|
const existing = prices.find(p => p.sign_family === signFamily && p.price_type === priceType && !p.service_type && p.id);
|
||||||
|
if (existing) {
|
||||||
|
const { error } = await supabase.from('company_prices').update({ price: Number(priceVal) }).eq('id', existing.id);
|
||||||
|
if (error) { setSavingSignFamilyPrice(null); alert('Failed to save price. Please try again.'); return; }
|
||||||
|
} else if (priceVal !== '') {
|
||||||
|
const { data, error } = await supabase.from('company_prices').insert({
|
||||||
|
company_id: id, sign_family: signFamily, price_type: priceType, price: Number(priceVal),
|
||||||
|
}).select().single();
|
||||||
|
if (error) { setSavingSignFamilyPrice(null); alert('Failed to save price. Please try again.'); return; }
|
||||||
|
if (data) setPrices(prev => [...prev.filter(p => !(p.sign_family === signFamily && p.price_type === priceType && !p.service_type && !p.id)), data]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSavingSignFamilyPrice(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <Layout><PageLoader /></Layout>;
|
||||||
if (!company) return <Layout><p>Company not found.</p></Layout>;
|
if (!company) return <Layout><p>Company not found.</p></Layout>;
|
||||||
|
|
||||||
const activeTasks = tasks.filter(t => t.status !== 'client_approved');
|
const activeTasks = tasks.filter(t => t.status !== 'client_approved');
|
||||||
@@ -293,15 +328,14 @@ export default function CompanyDetail() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div style={{ display: 'flex', gap: 4, marginBottom: 24, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 4, marginBottom: 10, flexWrap: 'wrap', minHeight: 'var(--btn-height)' }}>
|
||||||
{(isTeam ? ['users', 'projects', 'pricing'] : ['users', 'projects']).map(t => (
|
{(isTeam ? ['users', 'projects', 'pricing'] : ['users', 'projects']).map(t => (
|
||||||
<button
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
onClick={() => setTab(t)}
|
onClick={() => setTab(t)}
|
||||||
className={`tab-btn${tab === t ? ' active' : ''}`}
|
className={`section-tab-btn${tab === t ? ' is-active' : ''}`}
|
||||||
style={{ textTransform: 'capitalize' }}
|
|
||||||
>
|
>
|
||||||
{t}
|
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||||
{t === 'users' && availableUsers.length > 0 && (
|
{t === 'users' && availableUsers.length > 0 && (
|
||||||
<span style={{ marginLeft: 6, fontSize: 10, background: tab === t ? 'rgba(0,0,0,0.3)' : 'var(--danger)', color: 'white', padding: '1px 5px', borderRadius: 4, fontWeight: 400 }}>
|
<span style={{ marginLeft: 6, fontSize: 10, background: tab === t ? 'rgba(0,0,0,0.3)' : 'var(--danger)', color: 'white', padding: '1px 5px', borderRadius: 4, fontWeight: 400 }}>
|
||||||
{availableUsers.length}
|
{availableUsers.length}
|
||||||
@@ -317,7 +351,7 @@ export default function CompanyDetail() {
|
|||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-title">Assigned Users</div>
|
<div className="card-title">Assigned Users</div>
|
||||||
{users.length === 0 ? (
|
{users.length === 0 ? (
|
||||||
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>No users assigned to this company yet.</p>
|
<div className="card-empty-center">No users</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||||
{users.map((user, i) => (
|
{users.map((user, i) => (
|
||||||
@@ -469,10 +503,7 @@ export default function CompanyDetail() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{projects.length === 0 ? (
|
{projects.length === 0 ? (
|
||||||
<div className="empty-state">
|
<div className="card card-empty-center">No projects</div>
|
||||||
<h3>No projects yet</h3>
|
|
||||||
<p>Create a project to start adding jobs.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
{projects.map(project => {
|
{projects.map(project => {
|
||||||
@@ -480,7 +511,7 @@ export default function CompanyDetail() {
|
|||||||
const active = projectTasks.filter(t => t.status !== 'client_approved').length;
|
const active = projectTasks.filter(t => t.status !== 'client_approved').length;
|
||||||
const done = projectTasks.filter(t => t.status === 'client_approved').length;
|
const done = projectTasks.filter(t => t.status === 'client_approved').length;
|
||||||
return (
|
return (
|
||||||
<div key={project.id} className="interactive-surface" style={{ display: 'flex', alignItems: 'center', background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, overflow: 'hidden' }}>
|
<div key={project.id} className="interactive-surface" style={{ display: 'flex', alignItems: 'center', background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 4, overflow: 'hidden' }}>
|
||||||
<Link to={`/projects/${project.id}`} className="interactive-row" style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px', textDecoration: 'none', cursor: 'pointer' }}>
|
<Link to={`/projects/${project.id}`} className="interactive-row" style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px', textDecoration: 'none', cursor: 'pointer' }}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: 400, fontSize: 14, color: 'var(--text-primary)' }}>{project.name}</div>
|
<div style={{ fontWeight: 400, fontSize: 14, color: 'var(--text-primary)' }}>{project.name}</div>
|
||||||
@@ -496,7 +527,7 @@ export default function CompanyDetail() {
|
|||||||
{isTeam && <button
|
{isTeam && <button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleDeleteProject(project)}
|
onClick={() => handleDeleteProject(project)}
|
||||||
style={{ background: 'none', border: 'none', borderLeft: '1px solid var(--border)', color: 'var(--danger, #dc2626)', cursor: 'pointer', fontSize: 16, padding: '0 14px', alignSelf: 'stretch', display: 'flex', alignItems: 'center' }}
|
style={{ background: 'none', border: 'none', borderLeft: '1px solid var(--border)', color: 'var(--danger, var(--danger-strong))', cursor: 'pointer', fontSize: 16, padding: '0 14px', alignSelf: 'stretch', display: 'flex', alignItems: 'center' }}
|
||||||
title="Delete project"
|
title="Delete project"
|
||||||
>✕</button>}
|
>✕</button>}
|
||||||
</div>
|
</div>
|
||||||
@@ -549,6 +580,48 @@ export default function CompanyDetail() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 32 }}>
|
||||||
|
<div className="card-title" style={{ fontSize: 14, marginBottom: 8 }}>Sign Family Prices</div>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text-muted)', marginBottom: 16 }}>
|
||||||
|
Set prices per sign family for this company.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 130px 130px 60px', gap: 8, marginBottom: 8, alignItems: 'center' }}>
|
||||||
|
<div />
|
||||||
|
{['New', 'Revision'].map(label => (
|
||||||
|
<div key={label} style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', textAlign: 'right' }}>{label}</div>
|
||||||
|
))}
|
||||||
|
<div />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{signFamilies.map(sf => (
|
||||||
|
<div key={sf} style={{ display: 'grid', gridTemplateColumns: '1fr 130px 130px 60px', gap: 8, alignItems: 'center' }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 500 }}>{sf}</div>
|
||||||
|
{['new', 'revision'].map(priceType => (
|
||||||
|
<div key={priceType} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<span style={{ color: 'var(--text-muted)', fontSize: 14 }}>$</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="0.00"
|
||||||
|
value={getSignFamilyPrice(sf, priceType)}
|
||||||
|
onChange={e => handleSignFamilyPriceChange(sf, priceType, e.target.value)}
|
||||||
|
style={{ margin: 0, width: '100%', textAlign: 'right' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
className="btn btn-outline btn-sm"
|
||||||
|
onClick={() => handleSignFamilyPriceSave(sf)}
|
||||||
|
disabled={savingSignFamilyPrice === sf}
|
||||||
|
>
|
||||||
|
{savingSignFamilyPrice === sf ? '...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import JSZip from 'jszip';
|
import JSZip from 'jszip';
|
||||||
import { heicTo, isHeic } from 'heic-to/csp';
|
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
import LoadingButton from '../components/LoadingButton';
|
import LoadingButton from '../components/LoadingButton';
|
||||||
|
|
||||||
@@ -13,6 +12,14 @@ const OUTPUT_FORMATS = [
|
|||||||
const HEIC_EXTENSIONS = new Set(['heic', 'heif']);
|
const HEIC_EXTENSIONS = new Set(['heic', 'heif']);
|
||||||
const MAX_FILES = 100;
|
const MAX_FILES = 100;
|
||||||
|
|
||||||
|
// Lazy-load heic-to (WASM, ~2.7MB) only when a conversion actually runs,
|
||||||
|
// so visiting the page doesn't pull the whole library up front.
|
||||||
|
let _heicLibPromise = null;
|
||||||
|
function getHeicLib() {
|
||||||
|
if (!_heicLibPromise) _heicLibPromise = import('heic-to/csp');
|
||||||
|
return _heicLibPromise;
|
||||||
|
}
|
||||||
|
|
||||||
function getExtension(name = '') {
|
function getExtension(name = '') {
|
||||||
return name.split('.').pop()?.toLowerCase() || '';
|
return name.split('.').pop()?.toLowerCase() || '';
|
||||||
}
|
}
|
||||||
@@ -96,6 +103,7 @@ async function convertRasterBlob(blob, format, quality) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function convertHeicFile(file, format, quality) {
|
async function convertHeicFile(file, format, quality) {
|
||||||
|
const { heicTo } = await getHeicLib();
|
||||||
if (format.mime === 'image/jpeg' || format.mime === 'image/png') {
|
if (format.mime === 'image/jpeg' || format.mime === 'image/png') {
|
||||||
try {
|
try {
|
||||||
return await heicTo({ blob: file, type: format.mime, quality });
|
return await heicTo({ blob: file, type: format.mime, quality });
|
||||||
@@ -129,7 +137,9 @@ async function convertHeicFile(file, format, quality) {
|
|||||||
|
|
||||||
async function convertFile(file, format, quality) {
|
async function convertFile(file, format, quality) {
|
||||||
const looksHeic = isHeicFile(file);
|
const looksHeic = isHeicFile(file);
|
||||||
const confirmedHeic = looksHeic ? await isHeic(file).catch(() => false) : false;
|
const confirmedHeic = looksHeic
|
||||||
|
? await getHeicLib().then(({ isHeic }) => isHeic(file)).catch(() => false)
|
||||||
|
: false;
|
||||||
if (looksHeic || confirmedHeic) {
|
if (looksHeic || confirmedHeic) {
|
||||||
return convertHeicFile(file, format, quality);
|
return convertHeicFile(file, format, quality);
|
||||||
}
|
}
|
||||||
@@ -368,7 +378,7 @@ export default function Converters() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!files.length ? (
|
{!files.length ? (
|
||||||
<div style={{ color: 'var(--text-muted)', fontSize: 13 }}>No files added yet.</div>
|
<div className="card-empty-center">No files</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'grid', gap: 12 }}>
|
<div style={{ display: 'grid', gap: 12 }}>
|
||||||
{files.map((file, index) => {
|
{files.map((file, index) => {
|
||||||
@@ -398,7 +408,7 @@ export default function Converters() {
|
|||||||
<div style={{ fontSize: 12, color: 'var(--danger)', marginTop: 8 }}>{result.error}</div>
|
<div style={{ fontSize: 12, color: 'var(--danger)', marginTop: 8 }}>{result.error}</div>
|
||||||
)}
|
)}
|
||||||
{result?.status === 'done' && (
|
{result?.status === 'done' && (
|
||||||
<div style={{ fontSize: 12, color: 'var(--success, #16a34a)', marginTop: 8 }}>
|
<div style={{ fontSize: 12, color: 'var(--success, var(--success-strong))', marginTop: 8 }}>
|
||||||
Ready as {outputName}
|
Ready as {outputName}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,457 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import Layout from '../components/Layout';
|
|
||||||
import { supabase } from '../lib/supabase';
|
|
||||||
import { useAuth } from '../context/AuthContext';
|
|
||||||
import { readPageCache, writePageCache } from '../lib/pageCache';
|
|
||||||
import { withTimeout } from '../lib/withTimeout';
|
|
||||||
import SortTh from '../components/SortTh';
|
|
||||||
import { useSortable } from '../hooks/useSortable';
|
|
||||||
|
|
||||||
const ICON_TONES = [
|
|
||||||
{ bg: 'rgba(245,165,35,0.15)', color: '#F5A523' },
|
|
||||||
{ bg: 'rgba(74,222,128,0.15)', color: '#4ade80' },
|
|
||||||
{ bg: 'rgba(96,165,250,0.15)', color: '#60a5fa' },
|
|
||||||
{ bg: 'rgba(167,139,250,0.15)', color: '#a78bfa' },
|
|
||||||
];
|
|
||||||
|
|
||||||
function iconTone(key) {
|
|
||||||
let h = 0;
|
|
||||||
for (let i = 0; i < (key || '').length; i++) h = (h * 31 + key.charCodeAt(i)) % ICON_TONES.length;
|
|
||||||
return ICON_TONES[h];
|
|
||||||
}
|
|
||||||
|
|
||||||
function InitialPortrait({ name }) {
|
|
||||||
const tone = iconTone(name);
|
|
||||||
return (
|
|
||||||
<div style={{ width: 27, height: 27, borderRadius: '50%', background: tone.bg, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
|
||||||
<span style={{ fontSize: 12, fontWeight: 500, color: tone.color, lineHeight: 1 }}>{(name || '?')[0].toUpperCase()}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ExternalDashboard({ currentUser, projects, tasks, pos, submissions, clientProfiles, companyMemberships }) {
|
|
||||||
const myTasks = tasks.filter(t => t.assigned_to === currentUser?.id);
|
|
||||||
const myActiveTasks = myTasks.filter(t => !['client_approved', 'on_hold'].includes(t.status));
|
|
||||||
const myCompleted = myTasks.filter(t => t.status === 'client_approved');
|
|
||||||
const myCompletedRevisions = myCompleted.reduce((sum, t) => sum + Number(t.current_version || 0), 0);
|
|
||||||
const myOnHold = myTasks.filter(t => t.status === 'on_hold');
|
|
||||||
const myAssignedProjectCount = new Set(myTasks.map(t => t.project_id).filter(Boolean)).size;
|
|
||||||
const unpaidAmount = pos.filter(p => !['paid', 'cancelled'].includes(p.status)).reduce((s, p) => s + Number(p.amount || 0), 0);
|
|
||||||
const paidAmount = pos.filter(p => p.status === 'paid').reduce((s, p) => s + Number(p.amount || 0), 0);
|
|
||||||
|
|
||||||
const myProjectIds = new Set(myTasks.map(t => t.project_id).filter(Boolean));
|
|
||||||
const myProjects = myProjectIds.size > 0
|
|
||||||
? projects.filter(p => myProjectIds.has(p.id))
|
|
||||||
: projects;
|
|
||||||
const companyById = Object.fromEntries(myProjects.filter(p => p.company).map(p => [p.company_id, p.company]));
|
|
||||||
const myProjectIdSet = new Set(myProjects.map(p => p.id));
|
|
||||||
const activityEvents = buildActivityEvents(submissions, myProjectIdSet);
|
|
||||||
const externalHighlights = buildClientHighlights(Object.values(companyById), myProjects, tasks, clientProfiles, companyMemberships || [])
|
|
||||||
.sort((a, b) => b.openTaskCount - a.openTaskCount || a.company.name.localeCompare(b.company.name))
|
|
||||||
.slice(0, 5);
|
|
||||||
return (
|
|
||||||
<Layout>
|
|
||||||
<div className="dash-stat-grid">
|
|
||||||
<DashStatCard label="Active Tasks" value={myActiveTasks.length} sub={`${myOnHold.length} on hold`} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={DASH_ICONS.tasks} />
|
|
||||||
<DashStatCard label="Completed Tasks" value={myCompleted.length} sub={`${myCompletedRevisions} total revisions`} iconBg="rgba(74,222,128,0.15)" iconColor="#4ade80" iconPath={DASH_ICONS.trending} />
|
|
||||||
<DashStatCard label="Active Projects" value={myProjects.length} sub={`${myAssignedProjectCount} assigned to me`} iconBg="rgba(167,139,250,0.15)" iconColor="#a78bfa" iconPath={DASH_ICONS.projects} />
|
|
||||||
<DashStatCard label="Pending Payment" value={fmtMoney(unpaidAmount)} sub={`${fmtMoney(paidAmount)} paid`} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconPath={DASH_ICONS.invoice} />
|
|
||||||
</div>
|
|
||||||
<div className="dashboard-bottom-grid">
|
|
||||||
<ActivityFeed events={activityEvents} />
|
|
||||||
{myProjects.length > 0 && <ClientHighlightTable highlights={externalHighlights} />}
|
|
||||||
</div>
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Shared dashboard helpers ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
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"/> },
|
|
||||||
task_submitted: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: <><line x1="12" y1="19" x2="12" y2="5" strokeWidth="2" strokeLinecap="round"/><polyline points="5,12 12,5 19,12" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/></> },
|
|
||||||
task_approved: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: <polyline points="4,13 9,18 20,7" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/> },
|
|
||||||
task_on_hold: { bg: 'rgba(239,68,68,0.15)', color: '#ef4444', path: <><rect x="6" y="4" width="4" height="16" rx="1" fill="currentColor"/><rect x="14" y="4" width="4" height="16" rx="1" fill="currentColor"/></> },
|
|
||||||
task_created: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: <><line x1="12" y1="5" x2="12" y2="19" strokeWidth="2" strokeLinecap="round"/><line x1="5" y1="12" x2="19" y2="12" strokeWidth="2" strokeLinecap="round"/></> },
|
|
||||||
project_created: { bg: 'rgba(245,165,35,0.15)', color: '#F5A523', path: <><path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" fill="none" strokeWidth="1.5"/></> },
|
|
||||||
request_submitted: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: <><rect x="5" y="3" width="14" height="18" rx="2" fill="none" strokeWidth="1.5"/><line x1="9" y1="8" x2="15" y2="8" strokeWidth="1.5" strokeLinecap="round"/><line x1="9" y1="12" x2="15" y2="12" strokeWidth="1.5" strokeLinecap="round"/><line x1="9" y1="16" x2="12" y2="16" strokeWidth="1.5" strokeLinecap="round"/></> },
|
|
||||||
revision_requested: { bg: 'rgba(245,158,11,0.15)', color: '#f59e0b', path: <><path d="M4 12a8 8 0 018-8v0a8 8 0 018 8" fill="none" strokeWidth="1.5" strokeLinecap="round"/><polyline points="18,8 20,12 16,12" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none"/></> },
|
|
||||||
};
|
|
||||||
function ActionIcon({ actionKey, size = 27 }) {
|
|
||||||
const cfg = ACTION_ICON[actionKey] || { bg: 'rgba(255,255,255,0.08)', color: 'rgba(255,255,255,0.4)', path: <circle cx="12" cy="12" r="3" fill="currentColor"/> };
|
|
||||||
return (
|
|
||||||
<div style={{ width: size, height: size, borderRadius: '50%', background: cfg.bg, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" stroke={cfg.color} fill="none" style={{ color: cfg.color }}>{cfg.path}</svg>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ACTION_LABEL = {
|
|
||||||
task_created: 'created',
|
|
||||||
task_started: 'started',
|
|
||||||
task_on_hold: 'put on hold',
|
|
||||||
task_resumed: 'resumed',
|
|
||||||
task_submitted: 'submitted',
|
|
||||||
task_approved: 'approved',
|
|
||||||
project_created: 'created project',
|
|
||||||
request_submitted: 'submitted',
|
|
||||||
revision_requested: 'requested revision on',
|
|
||||||
};
|
|
||||||
|
|
||||||
function buildActivityEvents(activityData, projectIdSet = null) {
|
|
||||||
return (activityData || [])
|
|
||||||
.filter(e => !projectIdSet || !e.project_id || projectIdSet.has(e.project_id))
|
|
||||||
.map(e => ({
|
|
||||||
time: new Date(e.created_at),
|
|
||||||
name: e.actor_name || 'Fourge',
|
|
||||||
actionKey: e.action,
|
|
||||||
action: ACTION_LABEL[e.action] || e.action,
|
|
||||||
task: e.task_title || null,
|
|
||||||
project: e.project_name || null,
|
|
||||||
})).filter(e => !isNaN(e.time)).slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtMoney(n) {
|
|
||||||
if (Math.abs(n) >= 1000000) return `$${(n / 1000000).toFixed(2)}M`;
|
|
||||||
if (Math.abs(n) >= 10000) return `$${(n / 1000).toFixed(1)}k`;
|
|
||||||
return `$${Number(n).toFixed(2)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function smoothCurve(pts) {
|
|
||||||
if (pts.length < 2) return '';
|
|
||||||
let d = `M${pts[0][0].toFixed(1)},${pts[0][1].toFixed(1)}`;
|
|
||||||
for (let i = 1; i < pts.length; i++) {
|
|
||||||
const [x0, y0] = pts[i - 1];
|
|
||||||
const [x1, y1] = pts[i];
|
|
||||||
const cpX = (x0 + x1) / 2;
|
|
||||||
d += ` C${cpX.toFixed(1)},${y0.toFixed(1)} ${cpX.toFixed(1)},${y1.toFixed(1)} ${x1.toFixed(1)},${y1.toFixed(1)}`;
|
|
||||||
}
|
|
||||||
return d;
|
|
||||||
}
|
|
||||||
|
|
||||||
function MiniAreaChart({ data }) {
|
|
||||||
const W = 90, H = 42;
|
|
||||||
if (!data || data.length < 2) return null;
|
|
||||||
const max = Math.max(...data, 1);
|
|
||||||
const pts = data.map((v, i) => [
|
|
||||||
(i / (data.length - 1)) * W,
|
|
||||||
4 + (1 - v / max) * (H - 8),
|
|
||||||
]);
|
|
||||||
const line = smoothCurve(pts);
|
|
||||||
const lastPt = pts[pts.length - 1];
|
|
||||||
const firstPt = pts[0];
|
|
||||||
const area = `${line} L${lastPt[0].toFixed(1)},${H} L${firstPt[0].toFixed(1)},${H} Z`;
|
|
||||||
return (
|
|
||||||
<svg width="100%" height={H} viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" style={{ display: 'block', overflow: 'hidden' }}>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="areaGrad" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="5%" stopColor="#F5A523" stopOpacity="0.3" />
|
|
||||||
<stop offset="95%" stopColor="#F5A523" stopOpacity="0" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<path d={area} fill="url(#areaGrad)" />
|
|
||||||
<path d={line} fill="none" stroke="#F5A523" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DashStatCard({ label, value, sub, iconBg, iconColor, iconPath, chartData }) {
|
|
||||||
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', alignItems: 'stretch', gap: 21, minHeight: 120 }}>
|
|
||||||
<div style={{ flexShrink: 0, display: 'flex', flexDirection: 'column', ...(chartData ? {} : { flex: 1 }) }}>
|
|
||||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'rgba(255,255,255,0.7)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
|
|
||||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center' }}>
|
|
||||||
<div style={{ fontSize: 30, fontWeight: 400, color: '#ffffff', letterSpacing: -0.5, lineHeight: 1.1 }}>{value}</div>
|
|
||||||
</div>
|
|
||||||
{sub && <div style={{ fontSize: 12, color: 'rgba(255,255,255,0.5)', marginTop: 5 }}>{sub}</div>}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'space-between', ...(chartData ? { flex: 1, minWidth: 0 } : { flexShrink: 0 }) }}>
|
|
||||||
<div style={{ width: 27, height: 27, borderRadius: '50%', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
||||||
<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>
|
|
||||||
{chartData && <MiniAreaChart data={chartData} />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const DASH_ICONS = {
|
|
||||||
revenue: '<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6"/>',
|
|
||||||
projects: '<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>',
|
|
||||||
tasks: '<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/>',
|
|
||||||
invoice: '<rect x="2" y="2" width="20" height="20" rx="2"/><line x1="12" y1="6" x2="12" y2="18"/><path d="M16 8H9.5a2.5 2.5 0 000 5h5a2.5 2.5 0 010 5H8"/>',
|
|
||||||
trending: '<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/>',
|
|
||||||
profit: '<line x1="12" y1="20" x2="12" y2="4"/><polyline points="5 11 12 4 19 11"/>',
|
|
||||||
};
|
|
||||||
|
|
||||||
function StatBar({ items }) {
|
|
||||||
return (
|
|
||||||
<div className="stat-bar">
|
|
||||||
{items.map((item, i) => (
|
|
||||||
<div key={i} className="stat-bar-item">
|
|
||||||
<div className="stat-bar-header">
|
|
||||||
<div className="stat-bar-label">{item.label}</div>
|
|
||||||
<div className="stat-bar-dot" style={{ background: item.color }} />
|
|
||||||
</div>
|
|
||||||
<div className="stat-bar-value">{item.value}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TaskFeed({ title, tasks, projects, emptyMessage }) {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
return (
|
|
||||||
<div className="card" style={{ padding: 0, overflow: 'hidden', borderRadius: 4, flexShrink: 0 }}>
|
|
||||||
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)', background: 'var(--card-bg-2)', display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
||||||
<span style={{ fontSize: 13, fontWeight: 400, color: 'var(--text-primary)' }}>{title}</span>
|
|
||||||
{tasks.length > 0 && <span style={{ fontSize: 11, fontWeight: 400, padding: '1px 7px', borderRadius: 20, background: 'var(--accent)', color: '#1a1a1a' }}>{tasks.length}</span>}
|
|
||||||
</div>
|
|
||||||
{tasks.length === 0 ? (
|
|
||||||
<div style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)', fontSize: 13 }}>{emptyMessage}</div>
|
|
||||||
) : tasks.map((task, i) => {
|
|
||||||
const project = projects.find(p => p.id === task.project_id);
|
|
||||||
return (
|
|
||||||
<div key={task.id} onClick={() => navigate(`/requests/${task.id}`)} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '10px 16px', borderBottom: i < tasks.length - 1 ? '1px solid var(--border)' : 'none', cursor: 'pointer' }}>
|
|
||||||
<div style={{ minWidth: 0 }}>
|
|
||||||
<div style={{ fontSize: 13, fontWeight: 400, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{task.title}</div>
|
|
||||||
{project && <div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>{project.name}</div>}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
|
|
||||||
{task.assigned_name ? <>Assigned to <span style={{ color: 'var(--text-primary)' }}>{task.assigned_name}</span></> : 'Unassigned'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ActivityFeed({ events }) {
|
|
||||||
const visible = events.slice(0, 5);
|
|
||||||
return (
|
|
||||||
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', flexShrink: 0 }}>
|
|
||||||
<div style={{ marginBottom: visible.length > 0 ? 14 : 0 }}>
|
|
||||||
<span style={{ fontSize: 11, fontWeight: 500, color: 'rgba(255,255,255,0.7)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Recent Activity</span>
|
|
||||||
</div>
|
|
||||||
{visible.length === 0 ? (
|
|
||||||
<div style={{ fontSize: 13, color: 'rgba(255,255,255,0.5)', marginTop: 14 }}>No recent activity</div>
|
|
||||||
) : visible.map((e, i) => (
|
|
||||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: i > 0 ? 10 : 0 }}>
|
|
||||||
<ActionIcon actionKey={e.actionKey} />
|
|
||||||
<div style={{ flex: 1, minWidth: 0, fontSize: 13, lineHeight: 1.4 }}>
|
|
||||||
<span style={{ color: '#ffffff', fontWeight: 400 }}>{e.name}</span>
|
|
||||||
{e.action && <span style={{ color: 'rgba(255,255,255,0.5)' }}> {e.action}</span>}
|
|
||||||
{e.task && <span style={{ color: '#ffffff' }}> {e.task}</span>}
|
|
||||||
</div>
|
|
||||||
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)', whiteSpace: 'nowrap', flexShrink: 0 }}>{e.time.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ClientHighlightTable({ highlights }) {
|
|
||||||
const { sortKey, sortDir, toggle, sort } = useSortable('company');
|
|
||||||
if (!highlights || highlights.length === 0) return null;
|
|
||||||
const fmtMoney = (n) => `$${Number(n || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
||||||
const sortedHighlights = sort(highlights, (row, key) => {
|
|
||||||
if (key === 'company') return row.company?.name || '';
|
|
||||||
if (key === 'primaryContact') return row.primaryContact?.name || '';
|
|
||||||
if (key === 'projectCount') return row.projectCount || 0;
|
|
||||||
if (key === 'openTaskCount') return row.openTaskCount || 0;
|
|
||||||
if (key === 'outstandingTotal') return Number(row.outstandingTotal || 0);
|
|
||||||
if (key === 'paidTotal') return Number(row.paidTotal || 0);
|
|
||||||
return '';
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<div className="card" style={{ padding: 0, overflow: 'hidden', borderRadius: 4, flexShrink: 0 }}>
|
|
||||||
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)', background: 'var(--card-bg-2)' }}>
|
|
||||||
<span style={{ fontSize: 13, fontWeight: 400, color: 'var(--text-primary)' }}>Client Highlight</span>
|
|
||||||
</div>
|
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
|
||||||
<thead>
|
|
||||||
<tr style={{ background: 'var(--card-bg-2)' }}>
|
|
||||||
<SortTh col="company" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ padding: '8px 16px', textAlign: 'left', fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.4px', borderBottom: '1px solid var(--border)' }}>Company</SortTh>
|
|
||||||
<SortTh col="primaryContact" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ padding: '8px 16px', textAlign: 'center', fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.4px', borderBottom: '1px solid var(--border)' }}>Primary Contact</SortTh>
|
|
||||||
<SortTh col="projectCount" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ padding: '8px 16px', textAlign: 'center', fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.4px', borderBottom: '1px solid var(--border)' }}>Projects</SortTh>
|
|
||||||
<SortTh col="openTaskCount" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ padding: '8px 16px', textAlign: 'center', fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.4px', borderBottom: '1px solid var(--border)' }}>Open Tasks</SortTh>
|
|
||||||
<SortTh col="outstandingTotal" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ padding: '8px 16px', textAlign: 'right', fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.4px', borderBottom: '1px solid var(--border)' }}>Outstanding</SortTh>
|
|
||||||
<SortTh col="paidTotal" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ padding: '8px 16px', textAlign: 'right', fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.4px', borderBottom: '1px solid var(--border)' }}>Paid</SortTh>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{sortedHighlights.map(({ company, primaryContact, projectCount, openTaskCount, outstandingTotal = 0, paidTotal = 0 }, i) => (
|
|
||||||
<tr key={company.id} style={{ borderBottom: i < sortedHighlights.length - 1 ? '1px solid var(--border)' : 'none' }}>
|
|
||||||
<td style={{ padding: '10px 16px', fontSize: 13, fontWeight: 400, color: 'var(--text-primary)' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
||||||
<InitialPortrait name={company.name} />
|
|
||||||
{company.name}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '10px 16px', fontSize: 13, color: 'var(--text-secondary)', textAlign: 'center' }}>{primaryContact?.name || '—'}</td>
|
|
||||||
<td style={{ padding: '10px 16px', fontSize: 13, color: 'var(--text-secondary)', textAlign: 'center' }}>{projectCount}</td>
|
|
||||||
<td style={{ padding: '10px 16px', fontSize: 13, color: 'var(--text-primary)', textAlign: 'center' }}>{openTaskCount}</td>
|
|
||||||
<td style={{ padding: '10px 16px', fontSize: 13, color: 'var(--accent)', textAlign: 'right' }}>{fmtMoney(outstandingTotal)}</td>
|
|
||||||
<td style={{ padding: '10px 16px', fontSize: 13, color: 'var(--accent)', textAlign: 'right' }}>{fmtMoney(paidTotal)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Main export ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export default function DashboardPage() {
|
|
||||||
const { currentUser } = useAuth();
|
|
||||||
const isClient = currentUser?.role === 'client';
|
|
||||||
const isExternal = currentUser?.role === 'external';
|
|
||||||
|
|
||||||
// ── Client state ──────────────────────────────────────────────────────
|
|
||||||
const hasCompany = Boolean(currentUser?.company_id || currentUser?.company?.id || currentUser?.companies?.length);
|
|
||||||
const companies = isClient
|
|
||||||
? (currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : [])).slice().sort((a, b) => a.name.localeCompare(b.name))
|
|
||||||
: [];
|
|
||||||
const [allClientTasks, setAllClientTasks] = useState([]);
|
|
||||||
const [allClientProjects, setAllClientProjects] = useState([]);
|
|
||||||
const [allClientInvoices, setAllClientInvoices] = useState([]);
|
|
||||||
const [clientActivity, setClientActivity] = useState([]);
|
|
||||||
|
|
||||||
// ── External state ────────────────────────────────────────────────────
|
|
||||||
const cacheKey = 'team_dashboard_external';
|
|
||||||
const cached = isExternal ? readPageCache(cacheKey, 5 * 60_000) : null;
|
|
||||||
const [tasks, setTasks] = useState(() => cached?.tasks || []);
|
|
||||||
const [projects, setProjects] = useState(() => cached?.projects || []);
|
|
||||||
const [submissions, setSubmissions] = useState(() => cached?.submissions || []);
|
|
||||||
const [pos, setPos] = useState(() => cached?.pos || []);
|
|
||||||
const [clientProfiles, setClientProfiles] = useState(() => cached?.clientProfiles || []);
|
|
||||||
const [companyMemberships, setCompanyMemberships] = useState(() => cached?.companyMemberships || []);
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(() => isClient ? hasCompany : isExternal && !cached);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
if (isClient) {
|
|
||||||
if (!hasCompany) { setLoading(false); return; }
|
|
||||||
async function loadClient() {
|
|
||||||
try {
|
|
||||||
const [{ data: activeTasks }, { data: invoices }, { data: activityData }] = await withTimeout(Promise.all([
|
|
||||||
supabase.from('tasks').select('id, title, status, project_id, assigned_name, project:projects(id, name, company_id)').order('submitted_at', { ascending: false }),
|
|
||||||
supabase.from('invoices').select('total, status, company_id').in('status', ['sent', 'paid']),
|
|
||||||
supabase.from('activity_log').select('id, created_at, actor_name, action, task_title, project_name, project_id').order('created_at', { ascending: false }).limit(20),
|
|
||||||
]), 30000, 'Client dashboard load');
|
|
||||||
if (cancelled) return;
|
|
||||||
const clientTasks = activeTasks || [];
|
|
||||||
setAllClientTasks(clientTasks);
|
|
||||||
setAllClientInvoices(invoices || []);
|
|
||||||
setClientActivity(activityData || []);
|
|
||||||
const projectMap = {};
|
|
||||||
clientTasks.forEach(t => { if (t.project?.id) projectMap[t.project.id] = t.project; });
|
|
||||||
setAllClientProjects(Object.values(projectMap));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('ClientDashboard load failed:', error);
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
loadClient();
|
|
||||||
} else if (isExternal) {
|
|
||||||
async function loadExternal() {
|
|
||||||
try {
|
|
||||||
const [{ data: p }, { data: t }, { data: posData }, { data: memRows }, { data: clientProfiles }, { data: activityData }] = await withTimeout(Promise.all([
|
|
||||||
supabase.from('projects').select('id, name, company_id, company:companies(id, name)').order('created_at', { ascending: false }),
|
|
||||||
supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_to, assigned_name').order('submitted_at', { ascending: false }),
|
|
||||||
supabase.from('subcontractor_payments').select('id, amount, status').eq('profile_id', currentUser.id),
|
|
||||||
supabase.from('company_members').select('company_id, profile_id'),
|
|
||||||
supabase.from('profiles').select('id, name, email, company_id').eq('role', 'client'),
|
|
||||||
supabase.from('activity_log').select('id, created_at, actor_name, action, task_title, project_name, project_id').order('created_at', { ascending: false }).limit(20),
|
|
||||||
]), 30000, 'External dashboard load');
|
|
||||||
if (!cancelled) {
|
|
||||||
setProjects(p || []);
|
|
||||||
setTasks(t || []);
|
|
||||||
setPos(posData || []);
|
|
||||||
setSubmissions(activityData || []);
|
|
||||||
setClientProfiles(clientProfiles || []);
|
|
||||||
setCompanyMemberships(memRows || []);
|
|
||||||
writePageCache(cacheKey, { projects: p || [], tasks: t || [], submissions: activityData || [], pos: posData || [], clientProfiles: clientProfiles || [], companyMemberships: memRows || [] });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('External dashboard load failed:', error);
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
loadExternal();
|
|
||||||
} else {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [isClient, isExternal, hasCompany, cacheKey, currentUser?.id]);
|
|
||||||
|
|
||||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
|
||||||
|
|
||||||
// ── Client render ──────────────────────────────────────────────────────
|
|
||||||
if (isClient) {
|
|
||||||
const myCompanyIds = new Set(companies.map(c => c.id));
|
|
||||||
const myTasks = myCompanyIds.size > 0
|
|
||||||
? allClientTasks.filter(t => t.project?.company_id && myCompanyIds.has(t.project.company_id))
|
|
||||||
: allClientTasks;
|
|
||||||
const myProjects = myCompanyIds.size > 0
|
|
||||||
? allClientProjects.filter(p => myCompanyIds.has(p.company_id))
|
|
||||||
: allClientProjects;
|
|
||||||
const myInvoices = myCompanyIds.size > 0
|
|
||||||
? allClientInvoices.filter(i => myCompanyIds.has(i.company_id))
|
|
||||||
: allClientInvoices;
|
|
||||||
const myProjectIds = new Set(myProjects.map(p => p.id));
|
|
||||||
const reviewTasks = myTasks.filter(t => t.status === 'client_review');
|
|
||||||
const inProgressTasks = myTasks.filter(t => t.status === 'in_progress');
|
|
||||||
const onHoldTasks = myTasks.filter(t => t.status === 'on_hold');
|
|
||||||
const notStartedTasks = myTasks.filter(t => t.status === 'not_started');
|
|
||||||
const outstandingInvoices = myInvoices.filter(i => i.status === 'sent').reduce((sum, inv) => sum + Number(inv.total || 0), 0);
|
|
||||||
const paidInvoices = myInvoices.filter(i => i.status === 'paid').reduce((sum, inv) => sum + Number(inv.total || 0), 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Layout>
|
|
||||||
<div className="dash-stat-grid">
|
|
||||||
<DashStatCard label="Active Projects" value={myProjects.length} sub={`${myTasks.length} total tasks`} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={DASH_ICONS.projects} />
|
|
||||||
<DashStatCard label="Outstanding" value={fmtMoney(outstandingInvoices)} sub={`${fmtMoney(paidInvoices)} paid`} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconPath={DASH_ICONS.invoice} />
|
|
||||||
<DashStatCard label="In Progress" value={inProgressTasks.length} sub={`${notStartedTasks.length} not started`} iconBg="rgba(74,222,128,0.15)" iconColor="#4ade80" iconPath={DASH_ICONS.tasks} />
|
|
||||||
<DashStatCard label="Awaiting Review" value={reviewTasks.length} sub={`${onHoldTasks.length} on hold`} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconPath={DASH_ICONS.trending} />
|
|
||||||
</div>
|
|
||||||
<ActivityFeed events={buildActivityEvents(clientActivity, myProjectIds)} />
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginTop: 24 }}>
|
|
||||||
<TaskFeed title="Awaiting Your Review" tasks={reviewTasks} projects={myProjects} emptyMessage="No items need your review." />
|
|
||||||
<TaskFeed title="In Progress" tasks={inProgressTasks} projects={myProjects} emptyMessage="No items currently in progress." />
|
|
||||||
</div>
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── External render ────────────────────────────────────────────────────
|
|
||||||
if (isExternal) {
|
|
||||||
return <ExternalDashboard currentUser={currentUser} projects={projects} tasks={tasks} pos={pos} submissions={submissions} clientProfiles={clientProfiles} companyMemberships={companyMemberships} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import Layout from '../components/Layout';
|
|
||||||
import FileBrowser from '../components/FileBrowser';
|
|
||||||
|
|
||||||
export default function FileSharing() {
|
|
||||||
return (
|
|
||||||
<Layout>
|
|
||||||
<div className="page-header">
|
|
||||||
<div>
|
|
||||||
<div className="page-title">File Sharing</div>
|
|
||||||
<div className="page-subtitle">Shared workspace for team members and subcontractors.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<FileBrowser />
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -59,14 +59,14 @@ export default function Login() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{successMessage && <p style={{ color: '#22c55e', fontSize: 13, marginBottom: 12 }}>{successMessage}</p>}
|
{successMessage && <p style={{ color: 'var(--success)', fontSize: 13, marginBottom: 12 }}>{successMessage}</p>}
|
||||||
{error && <p style={{ color: '#ef4444', fontSize: 13, marginBottom: 12 }}>{error}</p>}
|
{error && <p style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 12 }}>{error}</p>}
|
||||||
<button type="submit" className="btn btn-primary w-full btn-lg" disabled={loading}>
|
<button type="submit" className="btn btn-primary w-full btn-lg" disabled={loading}>
|
||||||
{loading ? 'Signing in...' : 'Sign In'}
|
{loading ? 'Signing in...' : 'Sign In'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p style={{ textAlign: 'center', marginTop: 20, fontSize: 13, color: '#a8a8a8' }}>
|
<p style={{ textAlign: 'center', marginTop: 20, fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||||
Contact Fourge Branding to get access.
|
Contact Fourge Branding to get access.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useParams, useSearchParams } from 'react-router-dom';
|
import { useParams, useSearchParams } from 'react-router-dom';
|
||||||
|
import PageLoader from '../components/PageLoader';
|
||||||
|
|
||||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
||||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
||||||
@@ -74,30 +75,29 @@ export default function PayInvoice() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ minHeight: '100vh', background: '#f5f5f5', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: 24 }}>
|
<div style={{ minHeight: '100vh', background: '#f5f5f5', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: 24 }}>
|
||||||
|
{loading ? <PageLoader /> : null}
|
||||||
<div style={{ width: '100%', maxWidth: 480 }}>
|
<div style={{ width: '100%', maxWidth: 480 }}>
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||||
<img src="/fourge-logo.png" alt="Fourge Branding" style={{ height: 36, filter: 'invert(1)' }} />
|
<img src="/fourge-logo.png" alt="Fourge Branding" style={{ height: 36, filter: 'invert(1)' }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{!invoice ? (
|
||||||
<div style={{ textAlign: 'center', color: '#666' }}>Loading...</div>
|
<div style={{ background: '#fff', color: 'var(--surface-sunken)', borderRadius: 4, padding: 32, textAlign: 'center', boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}>
|
||||||
) : !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={{ fontSize: 18, fontWeight: 400, marginBottom: 8 }}>Invoice not found</div>
|
||||||
<div style={{ color: '#666' }}>This payment link may be invalid or expired.</div>
|
<div style={{ color: '#666' }}>This payment link may be invalid or expired.</div>
|
||||||
</div>
|
</div>
|
||||||
) : success || invoice.status === 'paid' ? (
|
) : success || invoice.status === 'paid' ? (
|
||||||
<div style={{ background: '#fff', color: '#141414', borderRadius: 4, padding: 32, textAlign: 'center', boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}>
|
<div style={{ background: '#fff', color: 'var(--surface-sunken)', borderRadius: 4, padding: 32, textAlign: 'center', boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}>
|
||||||
<div style={{ fontSize: 32, marginBottom: 12 }}>✓</div>
|
<div style={{ fontSize: 32, marginBottom: 12 }}>✓</div>
|
||||||
<div style={{ fontSize: 20, fontWeight: 400, marginBottom: 8 }}>Payment received</div>
|
<div style={{ fontSize: 20, fontWeight: 400, marginBottom: 8 }}>Payment received</div>
|
||||||
<div style={{ color: '#666', marginBottom: 4 }}>{invoice.invoice_number}</div>
|
<div style={{ color: '#666', marginBottom: 4 }}>{invoice.invoice_number}</div>
|
||||||
<div style={{ fontSize: 24, fontWeight: 400, color: '#16a34a', marginTop: 16 }}>{totalLabel}</div>
|
<div style={{ fontSize: 24, fontWeight: 400, color: 'var(--success-strong)', marginTop: 16 }}>{totalLabel}</div>
|
||||||
<div style={{ color: '#666', marginTop: 6, fontSize: 12, letterSpacing: '0.3px' }}>Charged in USD</div>
|
<div style={{ color: '#666', marginTop: 6, fontSize: 12, letterSpacing: '0.3px' }}>Charged in USD</div>
|
||||||
<div style={{ color: '#666', marginTop: 8, fontSize: 13 }}>Thank you for your payment. We'll be in touch!</div>
|
<div style={{ color: '#666', marginTop: 8, fontSize: 13 }}>Thank you for your payment. We'll be in touch!</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ background: '#fff', color: '#141414', borderRadius: 4, padding: 32, boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}>
|
<div style={{ background: '#fff', color: 'var(--surface-sunken)', borderRadius: 4, padding: 32, boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}>
|
||||||
<div style={{ fontSize: 13, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: '#999', marginBottom: 4 }}>Invoice</div>
|
<div style={{ fontSize: 13, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: '#999', marginBottom: 4 }}>Invoice</div>
|
||||||
<div style={{ fontSize: 22, fontWeight: 400, marginBottom: 4 }}>{invoice.invoice_number}</div>
|
<div style={{ fontSize: 22, fontWeight: 400, marginBottom: 4 }}>{invoice.invoice_number}</div>
|
||||||
<div style={{ color: '#666', marginBottom: 24 }}>{invoice.bill_to || company?.name}</div>
|
<div style={{ color: '#666', marginBottom: 24 }}>{invoice.bill_to || company?.name}</div>
|
||||||
@@ -105,27 +105,27 @@ export default function PayInvoice() {
|
|||||||
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '12px 0', borderTop: '1px solid #eee', borderBottom: '1px solid #eee', marginBottom: 24 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '12px 0', borderTop: '1px solid #eee', borderBottom: '1px solid #eee', marginBottom: 24 }}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 12, color: '#999', marginBottom: 2 }}>Invoice Date</div>
|
<div style={{ fontSize: 12, color: '#999', marginBottom: 2 }}>Invoice Date</div>
|
||||||
<div style={{ fontWeight: 400, color: '#141414' }}>{new Date(invoice.invoice_date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}</div>
|
<div style={{ fontWeight: 400, color: 'var(--surface-sunken)' }}>{new Date(invoice.invoice_date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ textAlign: 'right' }}>
|
<div style={{ textAlign: 'right' }}>
|
||||||
<div style={{ fontSize: 12, color: '#999', marginBottom: 2 }}>Due Date</div>
|
<div style={{ fontSize: 12, color: '#999', marginBottom: 2 }}>Due Date</div>
|
||||||
<div style={{ fontWeight: 400, color: '#141414' }}>{new Date(invoice.due_date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}</div>
|
<div style={{ fontWeight: 400, color: 'var(--surface-sunken)' }}>{new Date(invoice.due_date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||||
<div style={{ fontSize: 14, color: '#666' }}>Total Due</div>
|
<div style={{ fontSize: 14, color: '#666' }}>Total Due</div>
|
||||||
<div style={{ fontSize: 28, fontWeight: 400, color: '#141414' }}>{totalLabel}</div>
|
<div style={{ fontSize: 28, fontWeight: 400, color: 'var(--surface-sunken)' }}>{totalLabel}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{cancelled && (
|
{cancelled && (
|
||||||
<div style={{ background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 4, padding: '10px 14px', fontSize: 13, color: '#dc2626', marginBottom: 16 }}>
|
<div style={{ background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 4, padding: '10px 14px', fontSize: 13, color: 'var(--danger-strong)', marginBottom: 16 }}>
|
||||||
Payment was cancelled. You can try again below.
|
Payment was cancelled. You can try again below.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div style={{ background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 4, padding: '10px 14px', fontSize: 13, color: '#dc2626', marginBottom: 16 }}>
|
<div style={{ background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 4, padding: '10px 14px', fontSize: 13, color: 'var(--danger-strong)', marginBottom: 16 }}>
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -135,7 +135,7 @@ export default function PayInvoice() {
|
|||||||
disabled={paying}
|
disabled={paying}
|
||||||
style={{
|
style={{
|
||||||
width: '100%', padding: '14px', borderRadius: 4, border: 'none',
|
width: '100%', padding: '14px', borderRadius: 4, border: 'none',
|
||||||
background: paying ? '#999' : '#141414', color: '#fff',
|
background: paying ? '#999' : 'var(--surface-sunken)', color: '#fff',
|
||||||
fontSize: 16, fontWeight: 400, cursor: paying ? 'not-allowed' : 'pointer',
|
fontSize: 16, fontWeight: 400, cursor: paying ? 'not-allowed' : 'pointer',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,705 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import Layout from '../components/Layout';
|
||||||
|
import SortTh from '../components/SortTh';
|
||||||
|
import { supabase } from '../lib/supabase';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { useSortable } from '../hooks/useSortable';
|
||||||
|
import { popupOverlayStyle } from '../lib/popupStyles';
|
||||||
|
|
||||||
|
function smoothCurve(pts) {
|
||||||
|
if (pts.length < 2) return '';
|
||||||
|
let d = `M${pts[0][0].toFixed(1)},${pts[0][1].toFixed(1)}`;
|
||||||
|
for (let i = 1; i < pts.length; i++) {
|
||||||
|
const [x0, y0] = pts[i - 1]; const [x1, y1] = pts[i];
|
||||||
|
const cpX = (x0 + x1) / 2;
|
||||||
|
d += ` C${cpX.toFixed(1)},${y0.toFixed(1)} ${cpX.toFixed(1)},${y1.toFixed(1)} ${x1.toFixed(1)},${y1.toFixed(1)}`;
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiniAreaChart({ data, color = 'var(--accent)', gradId = 'ag1' }) {
|
||||||
|
const W = 90, H = 42;
|
||||||
|
if (!data || data.length < 2) return null;
|
||||||
|
const max = Math.max(...data, 1);
|
||||||
|
const pts = data.map((v, i) => [(i / (data.length - 1)) * W, 4 + (1 - v / max) * (H - 8)]);
|
||||||
|
const line = smoothCurve(pts);
|
||||||
|
const area = `${line} L${pts[pts.length-1][0].toFixed(1)},${H} L${pts[0][0].toFixed(1)},${H} Z`;
|
||||||
|
return (
|
||||||
|
<svg width="100%" height={H} viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" style={{ display: 'block', overflow: 'hidden' }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor={color} stopOpacity="0.3" />
|
||||||
|
<stop offset="95%" stopColor={color} stopOpacity="0" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path d={area} fill={`url(#${gradId})`} />
|
||||||
|
<path d={line} fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTaskStatus(status) {
|
||||||
|
if (status === 'in_progress') return 'In Progress';
|
||||||
|
if (status === 'on_hold') return 'On Hold';
|
||||||
|
if (status === 'client_review') return 'In Review';
|
||||||
|
return status ? status.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProfilePage() {
|
||||||
|
const { currentUser } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { id: profileId } = useParams();
|
||||||
|
const [loadingProfile, setLoadingProfile] = useState(false);
|
||||||
|
const [profileError, setProfileError] = useState('');
|
||||||
|
const [viewedProfile, setViewedProfile] = useState(null);
|
||||||
|
const [viewedCompanies, setViewedCompanies] = useState([]);
|
||||||
|
const [primaryCompanyAddress, setPrimaryCompanyAddress] = useState('');
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [savingProfile, setSavingProfile] = useState(false);
|
||||||
|
const [editError, setEditError] = useState('');
|
||||||
|
const [editForm, setEditForm] = useState({
|
||||||
|
name: '',
|
||||||
|
|
||||||
|
email: '',
|
||||||
|
website: '',
|
||||||
|
linkedin: '',
|
||||||
|
});
|
||||||
|
const [avatarUploading, setAvatarUploading] = useState(false);
|
||||||
|
const [activityItems, setActivityItems] = useState([]);
|
||||||
|
const [profileStats, setProfileStats] = useState(null);
|
||||||
|
const [profileTaskItems, setProfileTaskItems] = useState([]);
|
||||||
|
|
||||||
|
const isSelfView = !profileId || profileId === currentUser?.id;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
async function loadViewedProfile() {
|
||||||
|
if (!profileId || profileId === currentUser?.id) {
|
||||||
|
setLoadingProfile(false);
|
||||||
|
setViewedProfile(null);
|
||||||
|
setViewedCompanies([]);
|
||||||
|
setPrimaryCompanyAddress('');
|
||||||
|
setProfileError('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoadingProfile(true);
|
||||||
|
setProfileError('');
|
||||||
|
try {
|
||||||
|
const [{ data: profile, error }] = await Promise.all([
|
||||||
|
supabase.from('profiles').select('*').eq('id', profileId).single(),
|
||||||
|
]);
|
||||||
|
if (error || !profile) {
|
||||||
|
setProfileError('Unable to load profile.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [{ data: primaryCompany }, { data: memberships }] = await Promise.all([
|
||||||
|
profile.company_id
|
||||||
|
? supabase.from('companies').select('id, name, address').eq('id', profile.company_id).maybeSingle()
|
||||||
|
: Promise.resolve({ data: null }),
|
||||||
|
supabase
|
||||||
|
.from('company_members')
|
||||||
|
.select('company_id, company:companies(id, name, address)')
|
||||||
|
.eq('profile_id', profileId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
const names = new Set();
|
||||||
|
if (primaryCompany?.name) names.add(primaryCompany.name);
|
||||||
|
const primaryAddress = primaryCompany?.address || '';
|
||||||
|
(memberships || []).forEach((row) => {
|
||||||
|
const n = row?.company?.name;
|
||||||
|
if (n) names.add(n);
|
||||||
|
});
|
||||||
|
|
||||||
|
setViewedProfile(profile);
|
||||||
|
setViewedCompanies([...names]);
|
||||||
|
setPrimaryCompanyAddress(primaryAddress);
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setProfileError('Unable to load profile.');
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoadingProfile(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadViewedProfile();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [profileId, currentUser?.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const uid = isSelfView ? currentUser?.id : profileId;
|
||||||
|
if (!uid) return;
|
||||||
|
const doneStatuses = ['client_approved', 'invoiced', 'paid'];
|
||||||
|
const now = new Date();
|
||||||
|
const weeksBack = 10;
|
||||||
|
const monthsBack = 8;
|
||||||
|
const weekStart = new Date(now); weekStart.setDate(weekStart.getDate() - weeksBack * 7);
|
||||||
|
const monthStart = new Date(now); monthStart.setMonth(monthStart.getMonth() - monthsBack);
|
||||||
|
|
||||||
|
Promise.all([
|
||||||
|
supabase.from('tasks').select('id', { count: 'exact', head: true }).eq('assigned_to', uid).in('status', doneStatuses),
|
||||||
|
supabase.from('tasks').select('completed_at').eq('assigned_to', uid).in('status', doneStatuses).not('completed_at', 'is', null).gte('completed_at', weekStart.toISOString()),
|
||||||
|
supabase.from('project_members').select('project_id').eq('profile_id', uid),
|
||||||
|
supabase.from('tasks').select('project_id, submitted_at').eq('assigned_to', uid).not('project_id', 'is', null).gte('submitted_at', monthStart.toISOString()),
|
||||||
|
]).then(([completedTotal, completedRecent, members, tasksByMonth]) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
// weekly chart: count completed tasks per week (oldest → newest)
|
||||||
|
const weekBuckets = Array(weeksBack).fill(0);
|
||||||
|
(completedRecent.data || []).forEach(t => {
|
||||||
|
const msAgo = now - new Date(t.completed_at);
|
||||||
|
const weekIdx = weeksBack - 1 - Math.floor(msAgo / (7 * 86400000));
|
||||||
|
if (weekIdx >= 0 && weekIdx < weeksBack) weekBuckets[weekIdx]++;
|
||||||
|
});
|
||||||
|
|
||||||
|
// monthly chart: distinct active project_ids per month
|
||||||
|
const monthBuckets = Array(monthsBack).fill(0);
|
||||||
|
const monthSets = Array.from({ length: monthsBack }, () => new Set());
|
||||||
|
(tasksByMonth.data || []).forEach(t => {
|
||||||
|
const msAgo = now - new Date(t.submitted_at);
|
||||||
|
const mIdx = monthsBack - 1 - Math.floor(msAgo / (30.44 * 86400000));
|
||||||
|
if (mIdx >= 0 && mIdx < monthsBack) monthSets[mIdx].add(t.project_id);
|
||||||
|
});
|
||||||
|
monthSets.forEach((s, i) => { monthBuckets[i] = s.size; });
|
||||||
|
|
||||||
|
const projectIds = (members.data || []).map(m => m.project_id);
|
||||||
|
const fetchActive = projectIds.length > 0
|
||||||
|
? supabase.from('projects').select('id', { count: 'exact', head: true }).in('id', projectIds).not('status', 'in', '("completed","cancelled")')
|
||||||
|
: Promise.resolve({ count: 0 });
|
||||||
|
fetchActive.then(active => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setProfileStats({
|
||||||
|
tasksCompleted: completedTotal.count ?? 0,
|
||||||
|
tasksChart: weekBuckets,
|
||||||
|
activeProjects: active.count ?? 0,
|
||||||
|
projectsChart: monthBuckets,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [isSelfView, currentUser?.id, profileId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const uid = isSelfView ? currentUser?.id : profileId;
|
||||||
|
if (!uid) return;
|
||||||
|
|
||||||
|
async function loadProfileTasks() {
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('tasks')
|
||||||
|
.select('id, title, status, submitted_at')
|
||||||
|
.eq('assigned_to', uid)
|
||||||
|
.in('status', ['in_progress', 'on_hold', 'client_review'])
|
||||||
|
.order('submitted_at', { ascending: false })
|
||||||
|
.limit(20);
|
||||||
|
|
||||||
|
if (!cancelled) setProfileTaskItems(data || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadProfileTasks();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [isSelfView, currentUser?.id, profileId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const uid = isSelfView ? currentUser?.id : profileId;
|
||||||
|
if (!uid) return;
|
||||||
|
async function loadActivity() {
|
||||||
|
const [{ data: actorLogs }, { data: assignedTasks }] = await Promise.all([
|
||||||
|
supabase
|
||||||
|
.from('activity_log')
|
||||||
|
.select('id, created_at, action, task_id, task_title, project_name, project_id')
|
||||||
|
.eq('actor_id', uid)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(20),
|
||||||
|
supabase
|
||||||
|
.from('tasks')
|
||||||
|
.select('id')
|
||||||
|
.eq('assigned_to', uid),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const assignedTaskIds = (assignedTasks || []).map((t) => t.id).filter(Boolean);
|
||||||
|
let outcomeLogs = [];
|
||||||
|
if (assignedTaskIds.length > 0) {
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('activity_log')
|
||||||
|
.select('id, created_at, action, task_id, task_title, project_name, project_id')
|
||||||
|
.in('action', ['task_approved', 'revision_requested'])
|
||||||
|
.in('task_id', assignedTaskIds)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(20);
|
||||||
|
outcomeLogs = data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
const merged = [...(actorLogs || []), ...outcomeLogs];
|
||||||
|
const deduped = Array.from(new Map(merged.map((item) => [item.id, item])).values());
|
||||||
|
deduped.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||||
|
setActivityItems(deduped.slice(0, 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
loadActivity();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [isSelfView, currentUser?.id, profileId]);
|
||||||
|
|
||||||
|
const profile = useMemo(
|
||||||
|
() => isSelfView ? { ...(currentUser || {}), ...(viewedProfile || {}) } : viewedProfile,
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[isSelfView, currentUser?.id, currentUser?.name, currentUser?.email, currentUser?.role, currentUser?.website, currentUser?.linkedin, currentUser?.avatar_url, viewedProfile]
|
||||||
|
);
|
||||||
|
const profileScope = useMemo(() => {
|
||||||
|
const names = new Set();
|
||||||
|
if (isSelfView) {
|
||||||
|
(currentUser?.companies || []).forEach((row) => {
|
||||||
|
const name = row?.company?.name;
|
||||||
|
if (name) names.add(name);
|
||||||
|
});
|
||||||
|
if (currentUser?.company?.name) names.add(currentUser.company.name);
|
||||||
|
} else {
|
||||||
|
(viewedCompanies || []).forEach((name) => { if (name) names.add(name); });
|
||||||
|
}
|
||||||
|
|
||||||
|
const address = isSelfView
|
||||||
|
? (currentUser?.company?.address || currentUser?.companies?.[0]?.company?.address || '')
|
||||||
|
: (primaryCompanyAddress || '');
|
||||||
|
|
||||||
|
return {
|
||||||
|
companyNames: [...names],
|
||||||
|
companyAddress: address,
|
||||||
|
};
|
||||||
|
}, [isSelfView, currentUser, viewedCompanies, primaryCompanyAddress]);
|
||||||
|
|
||||||
|
const companyNames = profileScope.companyNames;
|
||||||
|
const companyAddress = profileScope.companyAddress;
|
||||||
|
const companyDisplayName = useMemo(() => {
|
||||||
|
const role = profile?.role;
|
||||||
|
if (role === 'team' || role === 'external') return 'Fourge Branding';
|
||||||
|
if (companyNames.length > 0) return companyNames.join(', ');
|
||||||
|
return 'No Company';
|
||||||
|
}, [companyNames, profile?.role]);
|
||||||
|
|
||||||
|
const memberSince = useMemo(() => {
|
||||||
|
const d = profile?.created_at ? new Date(profile.created_at) : null;
|
||||||
|
return d && !Number.isNaN(d.getTime())
|
||||||
|
? d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||||
|
: null;
|
||||||
|
}, [profile?.created_at]);
|
||||||
|
|
||||||
|
const handleAvatarUpload = async (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file || !currentUser?.id) return;
|
||||||
|
setAvatarUploading(true);
|
||||||
|
try {
|
||||||
|
const ext = file.name.split('.').pop();
|
||||||
|
const path = `${currentUser.id}/avatar.${ext}`;
|
||||||
|
const { error: upErr } = await supabase.storage.from('avatars').upload(path, file, { upsert: true });
|
||||||
|
if (upErr) { setEditError(upErr.message); return; }
|
||||||
|
const { data: { publicUrl } } = supabase.storage.from('avatars').getPublicUrl(path);
|
||||||
|
const bustedUrl = `${publicUrl}?t=${Date.now()}`;
|
||||||
|
const { data, error: dbErr } = await supabase.from('profiles').update({ avatar_url: bustedUrl }).eq('id', currentUser.id).select('*').single();
|
||||||
|
if (dbErr) { setEditError(dbErr.message); return; }
|
||||||
|
setViewedProfile(data);
|
||||||
|
} finally {
|
||||||
|
setAvatarUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const dashCardStyle = {
|
||||||
|
background: 'var(--card-bg)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '18px 21px',
|
||||||
|
backdropFilter: 'blur(12px)',
|
||||||
|
WebkitBackdropFilter: 'blur(12px)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const initials = (profile?.name || '')
|
||||||
|
.split(' ')
|
||||||
|
.map(n => n[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!profile) return;
|
||||||
|
setEditForm({
|
||||||
|
name: profile.name || '',
|
||||||
|
|
||||||
|
email: profile.email || '',
|
||||||
|
website: profile.website || '',
|
||||||
|
linkedin: profile.linkedin || '',
|
||||||
|
});
|
||||||
|
setEditError('');
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [profile?.name, profile?.email, profile?.website, profile?.linkedin]);
|
||||||
|
|
||||||
|
const setEditField = (field) => (e) => setEditForm((prev) => ({ ...prev, [field]: e.target.value }));
|
||||||
|
|
||||||
|
const handleProfileSave = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!currentUser?.id) return;
|
||||||
|
setSavingProfile(true);
|
||||||
|
setEditError('');
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: editForm.name.trim(),
|
||||||
|
|
||||||
|
email: editForm.email.trim(),
|
||||||
|
website: editForm.website.trim(),
|
||||||
|
linkedin: editForm.linkedin.trim(),
|
||||||
|
};
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('profiles')
|
||||||
|
.update(payload)
|
||||||
|
.eq('id', currentUser.id)
|
||||||
|
.select('*')
|
||||||
|
.single();
|
||||||
|
if (error) {
|
||||||
|
setEditError(error.message || 'Unable to update profile.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setViewedProfile(data || null);
|
||||||
|
setEditOpen(false);
|
||||||
|
} finally {
|
||||||
|
setSavingProfile(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const ACTION_ICON = {
|
||||||
|
task_started: { bg: 'color-mix(in srgb, var(--info) 15%, transparent)', color: 'var(--info)', path: <polygon points="6,4 20,12 6,20" fill="currentColor"/> },
|
||||||
|
task_resumed: { bg: 'color-mix(in srgb, var(--info) 15%, transparent)', color: 'var(--info)', path: <polygon points="6,4 20,12 6,20" fill="currentColor"/> },
|
||||||
|
task_submitted: { bg: 'color-mix(in srgb, var(--positive) 15%, transparent)', color: 'var(--positive)', path: <><line x1="12" y1="19" x2="12" y2="5" strokeWidth="2" strokeLinecap="round"/><polyline points="5,12 12,5 19,12" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/></> },
|
||||||
|
task_approved: { bg: 'color-mix(in srgb, var(--positive) 15%, transparent)', color: 'var(--positive)', path: <polyline points="4,13 9,18 20,7" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/> },
|
||||||
|
task_on_hold: { bg: 'color-mix(in srgb, var(--danger) 15%, transparent)', color: 'var(--danger)', path: <><rect x="6" y="4" width="4" height="16" rx="1" fill="currentColor"/><rect x="14" y="4" width="4" height="16" rx="1" fill="currentColor"/></> },
|
||||||
|
task_created: { bg: 'color-mix(in srgb, var(--violet) 15%, transparent)', color: 'var(--violet)', path: <><line x1="12" y1="5" x2="12" y2="19" strokeWidth="2" strokeLinecap="round"/><line x1="5" y1="12" x2="19" y2="12" strokeWidth="2" strokeLinecap="round"/></> },
|
||||||
|
project_created: { bg: 'color-mix(in srgb, var(--accent) 15%, transparent)', color: 'var(--accent)', path: <><path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" fill="none" strokeWidth="1.5"/></> },
|
||||||
|
request_submitted: { bg: 'color-mix(in srgb, var(--violet) 15%, transparent)', color: 'var(--violet)', path: <><rect x="5" y="3" width="14" height="18" rx="2" fill="none" strokeWidth="1.5"/><line x1="9" y1="8" x2="15" y2="8" strokeWidth="1.5" strokeLinecap="round"/><line x1="9" y1="12" x2="15" y2="12" strokeWidth="1.5" strokeLinecap="round"/><line x1="9" y1="16" x2="12" y2="16" strokeWidth="1.5" strokeLinecap="round"/></> },
|
||||||
|
revision_requested: { bg: 'color-mix(in srgb, var(--warning) 15%, transparent)', color: 'var(--warning)', path: <><path d="M4 12a8 8 0 018-8v0a8 8 0 018 8" fill="none" strokeWidth="1.5" strokeLinecap="round"/><polyline points="18,8 20,12 16,12" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none"/></> },
|
||||||
|
};
|
||||||
|
const ActionIcon = ({ actionKey, size = 27 }) => {
|
||||||
|
const cfg = ACTION_ICON[actionKey] || { bg: 'rgba(255,255,255,0.08)', color: 'rgba(255,255,255,0.4)', path: <circle cx="12" cy="12" r="3" fill="currentColor"/> };
|
||||||
|
return (
|
||||||
|
<div style={{ width: size, height: size, borderRadius: '50%', background: cfg.bg, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" stroke={cfg.color} fill="none" style={{ color: cfg.color }}>{cfg.path}</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTION_LABEL = {
|
||||||
|
task_created: 'Task created', task_started: 'Task started', task_on_hold: 'Task put on hold',
|
||||||
|
task_resumed: 'Task resumed', task_submitted: 'Task submitted', task_approved: 'Task approved',
|
||||||
|
project_created: 'Project created', request_submitted: 'Request submitted', revision_requested: 'Task rejected',
|
||||||
|
};
|
||||||
|
|
||||||
|
const toSentenceTitle = (value = '') => {
|
||||||
|
if (!value) return '';
|
||||||
|
return value.charAt(0).toUpperCase() + value.slice(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const profileTitleStyle = { fontSize: 18, fontWeight: 500, color: 'var(--text-primary)', lineHeight: 1.2 };
|
||||||
|
const profileSubStyle = { fontSize: 13, color: 'var(--text-secondary)', marginTop: 3 };
|
||||||
|
const profileBodyStyle = { fontSize: 13, color: 'var(--text-primary)' };
|
||||||
|
const metaLabelStyle = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8 };
|
||||||
|
const metaValueStyle = { fontSize: 13, color: 'var(--text-primary)', marginTop: 2 };
|
||||||
|
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 modalHelperStyle = { fontSize: 12, color: 'var(--text-muted)', marginTop: 6 };
|
||||||
|
const modalButtonStyle = {
|
||||||
|
lineHeight: 1,
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProfileTasksInProgressCard = ({ tasks = [] }) => {
|
||||||
|
const { sortKey, sortDir, toggle, sort } = useSortable('task');
|
||||||
|
const [showAll, setShowAll] = useState(false);
|
||||||
|
const rows = sort(tasks.slice(0, 10), (task, key) => {
|
||||||
|
if (key === 'task') return task.title || '';
|
||||||
|
if (key === 'status') return formatTaskStatus(task.status);
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
const visibleRows = showAll ? rows : rows.slice(0, 5);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ ...dashCardStyle, display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<div style={{ marginBottom: rows.length > 0 ? 14 : 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Tasks In Progress</span>
|
||||||
|
{rows.length > 5 && (
|
||||||
|
<button type="button" className="dashboard-inline-link" style={{ fontSize: 11, fontWeight: 500, letterSpacing: 0.4, textTransform: 'uppercase' }} onClick={() => setShowAll((value) => !value)}>
|
||||||
|
{showAll ? 'Show less' : 'Show all'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<div style={{ minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No tasks in progress</div>
|
||||||
|
) : (
|
||||||
|
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||||
|
<colgroup>
|
||||||
|
<col style={{ width: '65%' }} />
|
||||||
|
<col style={{ width: '35%' }} />
|
||||||
|
</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="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'right', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 5px 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>
|
||||||
|
Status
|
||||||
|
</SortTh>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{visibleRows.map((task) => (
|
||||||
|
<tr key={task.id} style={{ 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: 'right' }}>
|
||||||
|
{formatTaskStatus(task.status)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProfileActivityFeed = ({ items }) => (
|
||||||
|
<div style={{ ...dashCardStyle }}>
|
||||||
|
<div style={{ marginBottom: items.length > 0 ? 14 : 0 }}>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Recent Activity</span>
|
||||||
|
</div>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div style={{ minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No recent activity</div>
|
||||||
|
) : items.map((e, i) => (
|
||||||
|
<div key={e.id} style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: i > 0 ? 10 : 0 }}>
|
||||||
|
<ActionIcon actionKey={e.action} />
|
||||||
|
<div style={{ flex: 1, minWidth: 0, fontSize: 13, lineHeight: 1.4 }}>
|
||||||
|
<span style={{ color: 'var(--text-muted)' }}>{toSentenceTitle(ACTION_LABEL[e.action] || e.action)}</span>
|
||||||
|
{e.task_title && e.task_id && (
|
||||||
|
<><span style={{ color: 'var(--text-muted)' }}> </span>
|
||||||
|
<button type="button" className="dashboard-inline-link" onClick={() => navigate(`/tasks/${e.task_id}`)}>{e.task_title}</button></>
|
||||||
|
)}
|
||||||
|
{e.task_title && !e.task_id && <span style={{ color: 'var(--text-primary)' }}> {e.task_title}</span>}
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||||
|
{new Date(e.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||||
|
{loadingProfile && <div style={dashCardStyle}>Loading profile...</div>}
|
||||||
|
{!loadingProfile && profileError && <div style={{ ...dashCardStyle, color: 'var(--danger)' }}>{profileError}</div>}
|
||||||
|
{!loadingProfile && !profileError && (
|
||||||
|
<div className="profile-top-grid">
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||||
|
<div style={{
|
||||||
|
...dashCardStyle,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 20,
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundImage: `
|
||||||
|
radial-gradient(circle at 14% 88%, color-mix(in srgb, var(--accent) 10%, transparent) 0 2px, transparent 2px 16px),
|
||||||
|
radial-gradient(circle at 22% 80%, color-mix(in srgb, var(--accent) 8%, transparent) 0 2px, transparent 2px 16px),
|
||||||
|
radial-gradient(circle at 30% 72%, color-mix(in srgb, var(--accent) 7%, transparent) 0 2px, transparent 2px 16px),
|
||||||
|
radial-gradient(circle at 38% 64%, color-mix(in srgb, var(--accent) 6%, transparent) 0 2px, transparent 2px 16px),
|
||||||
|
radial-gradient(circle at 46% 56%, color-mix(in srgb, var(--accent) 5%, transparent) 0 2px, transparent 2px 16px),
|
||||||
|
radial-gradient(circle at 54% 48%, color-mix(in srgb, var(--accent) 4%, transparent) 0 2px, transparent 2px 16px),
|
||||||
|
radial-gradient(circle at 62% 40%, color-mix(in srgb, var(--accent) 3%, transparent) 0 2px, transparent 2px 16px),
|
||||||
|
radial-gradient(circle at 70% 32%, color-mix(in srgb, var(--accent) 2%, transparent) 0 2px, transparent 2px 16px)
|
||||||
|
`,
|
||||||
|
}}>
|
||||||
|
<div style={{ position: 'absolute', top: 18, right: 21, bottom: 18, display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'space-between' }}>
|
||||||
|
{isSelfView && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline"
|
||||||
|
onClick={() => setEditOpen(true)}
|
||||||
|
>
|
||||||
|
Edit Profile
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 10, marginTop: 'auto' }}>
|
||||||
|
{memberSince && (
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<div style={metaLabelStyle}>Member Since</div>
|
||||||
|
<div style={metaValueStyle}>{memberSince}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{profile?.role && (
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<div style={metaLabelStyle}>Role</div>
|
||||||
|
<div style={metaValueStyle}>
|
||||||
|
{profile.role === 'team' ? 'Team' : profile.role === 'external' ? 'Subcontractor' : profile.role === 'client' ? 'Client' : profile.role}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ width: 140, height: 140, flexShrink: 0, borderRadius: '50%', background: 'var(--card-bg-2)', border: '2px solid var(--avatar-inner-ring)', outline: '2px solid var(--accent)', outlineOffset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 36, color: 'var(--text-primary)', fontWeight: 500, lineHeight: 1, overflow: 'hidden' }}>
|
||||||
|
{profile?.avatar_url
|
||||||
|
? <img src={profile.avatar_url} alt={profile.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
|
: (initials || '?')}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={profileTitleStyle}>{profile?.name || '—'}</div>
|
||||||
|
<div style={profileSubStyle}>
|
||||||
|
{companyDisplayName}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 14, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{companyAddress && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0, position: 'relative', top: -2 }}><path d="M12 22s-8-4.5-8-11.8A8 8 0 0112 2a8 8 0 018 8.2c0 7.3-8 11.8-8 11.8z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||||
|
<span style={profileBodyStyle}>{companyAddress}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{profile?.email && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0, position: 'relative', top: -2 }}><rect x="2" y="4" width="20" height="16" rx="2"/><polyline points="2,4 12,13 22,4"/></svg>
|
||||||
|
<span style={profileBodyStyle}>{profile.email}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{profile?.website && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0, position: 'relative', top: -2 }}><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 010 20M12 2a15.3 15.3 0 000 20"/></svg>
|
||||||
|
<a href={profile.website.startsWith('http') ? profile.website : `https://${profile.website}`} target="_blank" rel="noreferrer" style={{ ...profileBodyStyle, textDecoration: 'none' }}>{profile.website.replace(/^https?:\/\/(www\.)?/, '')}</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{profile?.linkedin && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="var(--text-muted)" style={{ flexShrink: 0, position: 'relative', top: -2 }}><path d="M16 8a6 6 0 016 6v7h-4v-7a2 2 0 00-2-2 2 2 0 00-2 2v7h-4v-7a6 6 0 016-6z"/><rect x="2" y="9" width="4" height="12"/><circle cx="4" cy="4" r="2"/></svg>
|
||||||
|
<a href={profile.linkedin.startsWith('http') ? profile.linkedin : `https://${profile.linkedin}`} target="_blank" rel="noreferrer" style={{ ...profileBodyStyle, textDecoration: 'none' }}>{profile.linkedin.replace(/^https?:\/\/(www\.)?/, '')}</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{profileStats && (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24 }}>
|
||||||
|
{/* Tasks Completed — weekly */}
|
||||||
|
<div style={{ ...dashCardStyle, display: 'flex', flexDirection: 'column', minHeight: 120 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'stretch', gap: 21 }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>Tasks Completed</div>
|
||||||
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center' }}>
|
||||||
|
<div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{profileStats.tasksCompleted}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||||
|
<div style={{ width: 27, height: 27, borderRadius: '50%', background: 'color-mix(in srgb, var(--positive) 15%, transparent)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--positive)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" dangerouslySetInnerHTML={{ __html: '<polyline points="4,13 9,18 20,7"/>' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<MiniAreaChart data={profileStats.tasksChart} color="var(--positive)" gradId="tcGrad" />
|
||||||
|
</div>
|
||||||
|
{/* Active Projects — monthly */}
|
||||||
|
<div style={{ ...dashCardStyle, display: 'flex', flexDirection: 'column', minHeight: 120 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'stretch', gap: 21 }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>Active Clients</div>
|
||||||
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center' }}>
|
||||||
|
<div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{profileStats.activeProjects}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||||
|
<div style={{ width: 27, height: 27, borderRadius: '50%', background: 'color-mix(in srgb, var(--accent) 15%, transparent)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" dangerouslySetInnerHTML={{ __html: '<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" fill="none"/>' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<MiniAreaChart data={profileStats.projectsChart} color="var(--accent)" gradId="apGrad" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||||
|
<ProfileTasksInProgressCard tasks={profileTaskItems} />
|
||||||
|
<ProfileActivityFeed items={activityItems} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isSelfView && editOpen && (
|
||||||
|
<div
|
||||||
|
style={popupOverlayStyle}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...dashCardStyle,
|
||||||
|
width: 'min(434px, 100%)',
|
||||||
|
maxHeight: 'calc(100vh - 48px)',
|
||||||
|
overflowY: 'auto',
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div style={{ ...metaLabelStyle, marginBottom: 14 }}>Edit Profile</div>
|
||||||
|
<form onSubmit={handleProfileSave}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 20 }}>
|
||||||
|
<div style={{ width: 72, height: 72, borderRadius: '50%', background: 'var(--card-bg-2)', border: '2px solid var(--avatar-inner-ring)', outline: '2px solid var(--accent)', outlineOffset: 0, flexShrink: 0, overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 24, color: 'var(--text-primary)', fontWeight: 500 }}>
|
||||||
|
{profile?.avatar_url
|
||||||
|
? <img src={profile.avatar_url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
|
: (initials || '?')}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="avatar-upload" style={{ cursor: 'pointer', display: 'inline-block' }}>
|
||||||
|
<span className="btn btn-outline" style={{ ...modalButtonStyle, display: 'inline-flex', alignItems: 'center' }}>
|
||||||
|
{avatarUploading ? 'Uploading…' : 'Change Photo'}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input id="avatar-upload" type="file" accept="image/*" style={{ display: 'none' }} onChange={handleAvatarUpload} disabled={avatarUploading} />
|
||||||
|
<div style={modalHelperStyle}>JPG, PNG or WebP</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label style={modalLabelStyle}>Name</label>
|
||||||
|
<input type="text" value={editForm.name} onChange={setEditField('name')} style={modalInputStyle} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label style={modalLabelStyle}>Email</label>
|
||||||
|
<input type="email" value={editForm.email} onChange={setEditField('email')} style={modalInputStyle} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label style={modalLabelStyle}>Website</label>
|
||||||
|
<input type="text" value={editForm.website} onChange={setEditField('website')} placeholder="example.com" style={modalInputStyle} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label style={modalLabelStyle}>LinkedIn</label>
|
||||||
|
<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 className="modal-action-row" style={{ marginTop: 6 }}>
|
||||||
|
<button type="submit" className="btn btn-outline" disabled={savingProfile} style={modalButtonStyle}>
|
||||||
|
{savingProfile ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-outline" onClick={() => setEditOpen(false)} disabled={savingProfile} style={modalButtonStyle}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,661 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useParams, 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 SortTh from '../components/SortTh';
|
||||||
|
import FilterDropdown from '../components/FilterDropdown';
|
||||||
|
import { supabase } from '../lib/supabase';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { logActivity } from '../lib/activityLog';
|
||||||
|
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 { 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_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' };
|
||||||
|
|
||||||
|
export default function ProjectDetailPage() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const { currentUser } = useAuth();
|
||||||
|
|
||||||
|
const isClient = currentUser?.role === 'client';
|
||||||
|
const isTeam = currentUser?.role === 'team';
|
||||||
|
|
||||||
|
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 [loading, setLoading] = useState(true);
|
||||||
|
const [submissions, setSubmissions] = useState([]);
|
||||||
|
const [deliveries, setDeliveries] = useState([]);
|
||||||
|
const [activeTab, setActiveTab] = useState('tasks');
|
||||||
|
const [filterStatus, setFilterStatus] = useState('all');
|
||||||
|
const [filterRevision, setFilterRevision] = useState('all');
|
||||||
|
const [filterType, setFilterType] = useState('all');
|
||||||
|
const [filterAssigned, setFilterAssigned] = useState('all');
|
||||||
|
|
||||||
|
const [editingName, setEditingName] = useState(false);
|
||||||
|
const [nameVal, setNameVal] = useState('');
|
||||||
|
const [savingName, setSavingName] = useState(false);
|
||||||
|
|
||||||
|
const [showClientTask, setShowClientTask] = useState(false);
|
||||||
|
const [clientTaskKey, setClientTaskKey] = useState(0);
|
||||||
|
const [clientTaskSaving, setClientTaskSaving] = useState(false);
|
||||||
|
const [clientTaskError, setClientTaskError] = useState('');
|
||||||
|
const [clientTaskRequestKey, setClientTaskRequestKey] = useState(() => crypto.randomUUID());
|
||||||
|
|
||||||
|
const [addingMember, setAddingMember] = useState(false);
|
||||||
|
const [selectedExts, setSelectedExts] = useState(new Set());
|
||||||
|
const [savingMembers, setSavingMembers] = useState(false);
|
||||||
|
const [subSortKey, setSubSortKey] = useState('name');
|
||||||
|
const [subSortDir, setSubSortDir] = useState('asc');
|
||||||
|
const toggleSubSort = (col) => { if (subSortKey === col) setSubSortDir(d => d === 'asc' ? 'desc' : 'asc'); else { setSubSortKey(col); setSubSortDir('asc'); } };
|
||||||
|
const [subProjectCounts, setSubProjectCounts] = useState({});
|
||||||
|
const [activityLog, setActivityLog] = useState([]);
|
||||||
|
const [companyClients, setCompanyClients] = useState([]);
|
||||||
|
|
||||||
|
const { sortKey, sortDir, toggle, sort } = useSortable('submitted_at');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function load() {
|
||||||
|
const { data: p } = await supabase.from('projects').select('*').eq('id', id).single();
|
||||||
|
if (!p) { setLoading(false); return; }
|
||||||
|
setProject(p);
|
||||||
|
const [{ data: co }, { data: t }] = await Promise.all([
|
||||||
|
supabase.from('companies').select('*').eq('id', p.company_id).single(),
|
||||||
|
supabase.from('tasks').select('*, assignee:profiles!assigned_to(avatar_url)').eq('project_id', id).order('submitted_at', { ascending: false }),
|
||||||
|
]);
|
||||||
|
setCompany(co);
|
||||||
|
setTasks(t || []);
|
||||||
|
const taskIds = (t || []).map(tk => tk.id);
|
||||||
|
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),
|
||||||
|
]);
|
||||||
|
setMembers(pm || []);
|
||||||
|
const seen = new Set();
|
||||||
|
const merged = [];
|
||||||
|
(users || []).forEach(u => { if (!seen.has(u.id)) { seen.add(u.id); merged.push(u); } });
|
||||||
|
(viaMembers || []).forEach(m => { if (m.profile?.role === 'client' && !seen.has(m.profile.id)) { seen.add(m.profile.id); merged.push(m.profile); } });
|
||||||
|
setCompanyClients(merged);
|
||||||
|
const { data: act, error: actErr } = await supabase.from('activity_log').select('id, created_at, actor_name, action, task_title').eq('project_id', id).order('created_at', { ascending: false }).limit(50);
|
||||||
|
if (actErr) console.error('activity_log fetch:', actErr);
|
||||||
|
setActivityLog((act || []).filter(e => ['task_started', 'task_on_hold', 'task_approved'].includes(e.action)));
|
||||||
|
if (isTeam) {
|
||||||
|
const { data: ext } = await supabase.from('profiles').select('id, name, avatar_url, email').eq('role', 'external').order('name');
|
||||||
|
setExtProfs(ext || []);
|
||||||
|
if ((ext || []).length > 0) {
|
||||||
|
const { data: pmRows } = await supabase.from('project_members').select('profile_id').in('profile_id', ext.map(p => p.id));
|
||||||
|
const counts = {};
|
||||||
|
(pmRows || []).forEach(r => { counts[r.profile_id] = (counts[r.profile_id] || 0) + 1; });
|
||||||
|
setSubProjectCounts(counts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
}, [id, refreshKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const handleSaveName = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!nameVal.trim()) return;
|
||||||
|
setSavingName(true);
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddMember = async () => {
|
||||||
|
setSavingMembers(true);
|
||||||
|
const currentIds = new Set(members.filter(m => m.profile?.role === 'external').map(m => m.profile_id));
|
||||||
|
const toAdd = [...selectedExts].filter(pid => !currentIds.has(pid));
|
||||||
|
const toRemove = [...currentIds].filter(pid => !selectedExts.has(pid));
|
||||||
|
if (toAdd.length > 0) {
|
||||||
|
const { data } = await supabase.from('project_members').insert(toAdd.map(pid => ({ project_id: id, profile_id: pid }))).select('*, profile:profiles(id, name, avatar_url, email, role)');
|
||||||
|
if (data) setMembers(prev => [...prev, ...data]);
|
||||||
|
}
|
||||||
|
if (toRemove.length > 0) {
|
||||||
|
for (const pid of toRemove) {
|
||||||
|
await supabase.from('project_members').delete().eq('project_id', id).eq('profile_id', pid);
|
||||||
|
}
|
||||||
|
setMembers(prev => prev.filter(m => !toRemove.includes(m.profile_id)));
|
||||||
|
}
|
||||||
|
setSelectedExts(new Set());
|
||||||
|
setAddingMember(false);
|
||||||
|
setSavingMembers(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
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.');
|
||||||
|
const { submission } = await createInitialSubmissionForRequest({
|
||||||
|
taskId: task.id, requestKey: clientTaskRequestKey, isHot: formData.isHot,
|
||||||
|
serviceType: formData.serviceType, signFamily: formData.signFamily,
|
||||||
|
signCount: formData.signCount, signs: formData.signs,
|
||||||
|
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}/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 });
|
||||||
|
try {
|
||||||
|
await uploadRequestFileToSurvey({
|
||||||
|
projectId: project.id,
|
||||||
|
taskTitle: formData.title.trim(),
|
||||||
|
storagePath: path,
|
||||||
|
fileName: file.name,
|
||||||
|
});
|
||||||
|
} catch (mirrorError) {
|
||||||
|
console.error('Survey folder mirror failed:', mirrorError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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, 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());
|
||||||
|
} catch (err) { setClientTaskError(err.message); }
|
||||||
|
finally { setClientTaskSaving(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <Layout><PageLoader /></Layout>;
|
||||||
|
if (!project) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Project not found.</p></Layout>;
|
||||||
|
|
||||||
|
const rows = tasks.map(task => {
|
||||||
|
const derived = getTaskDerivedState(task, submissions, deliveries);
|
||||||
|
return {
|
||||||
|
id: task.id, rowKey: task.id, title: task.title, status: task.status,
|
||||||
|
version: derived.currentVersion,
|
||||||
|
assignedName: task.assigned_name || null, assigneeAvatar: task.assignee?.avatar_url || null, assignedTo: task.assigned_to || null,
|
||||||
|
serviceType: derived.serviceType, deadline: derived.deadline, isHot: derived.isHot,
|
||||||
|
submittedAt: derived.latestActivityAt ? new Date(derived.latestActivityAt).toISOString() : (task.submitted_at || ''),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedRows = sort(rows, (r, key) => {
|
||||||
|
if (key === 'title') return r.title || '';
|
||||||
|
if (key === 'assigned') return r.assignedName || '';
|
||||||
|
if (key === 'revision') return r.version;
|
||||||
|
if (key === 'status') return r.status || '';
|
||||||
|
if (key === 'serviceType') return r.serviceType || '';
|
||||||
|
if (key === 'deadline') return r.deadline || '';
|
||||||
|
if (key === 'priority') return r.isHot ? 0 : 1;
|
||||||
|
if (key === 'submitted_at') return r.submittedAt;
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const doneStatuses = new Set(['client_approved', 'invoiced', 'paid']);
|
||||||
|
const taskTabs = [
|
||||||
|
{ id: 'tasks', label: 'Tasks' },
|
||||||
|
{ id: 'completed', label: 'Completed' },
|
||||||
|
...(isTeam ? [{ id: 'members', label: 'Subcontractors' }] : []),
|
||||||
|
];
|
||||||
|
const tabBaseRows = activeTab === 'completed'
|
||||||
|
? sortedRows.filter(r => doneStatuses.has(r.status))
|
||||||
|
: sortedRows.filter(r => !doneStatuses.has(r.status));
|
||||||
|
let visibleRows = tabBaseRows;
|
||||||
|
if (filterStatus !== 'all') visibleRows = visibleRows.filter(r => r.status === filterStatus);
|
||||||
|
if (filterRevision === 'new') visibleRows = visibleRows.filter(r => Number(r.version || 0) === 0);
|
||||||
|
else if (filterRevision === 'revision') visibleRows = visibleRows.filter(r => Number(r.version || 0) >= 1);
|
||||||
|
if (filterType !== 'all') visibleRows = visibleRows.filter(r => (r.serviceType || '') === filterType);
|
||||||
|
if (filterAssigned === 'unassigned') visibleRows = visibleRows.filter(r => !r.assignedTo);
|
||||||
|
else if (filterAssigned !== 'all') visibleRows = visibleRows.filter(r => r.assignedTo === filterAssigned);
|
||||||
|
|
||||||
|
const STATUS_FILTER_OPTIONS = activeTab === 'completed'
|
||||||
|
? [
|
||||||
|
{ value: 'all', label: 'All Statuses' },
|
||||||
|
{ value: 'client_approved', label: 'Approved' },
|
||||||
|
{ value: 'invoiced', label: 'Invoiced' },
|
||||||
|
{ value: 'paid', label: 'Paid' },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ value: 'all', label: 'All Statuses' },
|
||||||
|
{ value: 'not_started', label: 'Not Started' },
|
||||||
|
{ value: 'in_progress', label: 'In Progress' },
|
||||||
|
{ value: 'on_hold', label: 'On Hold' },
|
||||||
|
{ value: 'client_review', label: 'In Review' },
|
||||||
|
];
|
||||||
|
const REVISION_FILTER_OPTIONS = [
|
||||||
|
{ value: 'all', label: 'All' },
|
||||||
|
{ value: 'new', label: 'New (R00)' },
|
||||||
|
{ value: 'revision', label: 'Revisions (R1+)' },
|
||||||
|
];
|
||||||
|
const TYPE_FILTER_OPTIONS = [
|
||||||
|
{ value: 'all', label: 'All Types' },
|
||||||
|
...[...new Set(tabBaseRows.map(r => r.serviceType).filter(Boolean))].sort((a, b) => a.localeCompare(b)).map(t => ({ value: t, label: t })),
|
||||||
|
];
|
||||||
|
const ASSIGNED_FILTER_OPTIONS = [
|
||||||
|
{ value: 'all', label: 'All Assignees' },
|
||||||
|
{ value: 'unassigned', label: 'Unassigned' },
|
||||||
|
...[...new Map(tabBaseRows.filter(r => r.assignedTo).map(r => [r.assignedTo, r.assignedName || '—'])).entries()]
|
||||||
|
.sort((a, b) => a[1].localeCompare(b[1]))
|
||||||
|
.map(([id, name]) => ({ value: id, label: name })),
|
||||||
|
];
|
||||||
|
|
||||||
|
const fmtDate = (d) => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
||||||
|
|
||||||
|
{/* Left 70% */}
|
||||||
|
<div style={{ flex: '0 0 70%', minWidth: 0, display: 'flex', flexDirection: 'column', gap: 24, minHeight: 0 }}>
|
||||||
|
|
||||||
|
{/* Header card */}
|
||||||
|
<div className="card" style={CARD}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, marginBottom: 8 }}>
|
||||||
|
{editingName && isTeam ? (
|
||||||
|
<form onSubmit={handleSaveName} style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1 }}>
|
||||||
|
<input autoFocus required value={nameVal} onChange={e => setNameVal(e.target.value)} style={{ ...MODAL_IN, fontSize: 20, padding: '4px 10px', flex: 1 }} />
|
||||||
|
<button type="submit" className="btn btn-outline" disabled={savingName}>Save</button>
|
||||||
|
<button type="button" className="btn btn-outline" onClick={() => setEditingName(false)}>Cancel</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: 28, fontWeight: 500, color: 'var(--text-primary)', lineHeight: 1.2 }}>{project.name}</div>
|
||||||
|
)}
|
||||||
|
{isClient && (
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||||
|
<button className="btn btn-outline" onClick={() => setShowClientTask(true)}>+ Task</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 20 }}>
|
||||||
|
<StatusBadge status={project.status} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
const approved = tasks.filter(t => ['client_approved','invoiced','paid'].includes(t.status)).length;
|
||||||
|
const pct = tasks.length > 0 ? Math.round((approved / tasks.length) * 100) : 0;
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 30, flexWrap: 'wrap' }}>
|
||||||
|
{!isClient && company && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<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 }}><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><polyline points="9,22 9,12 15,12 15,22"/></svg>
|
||||||
|
<div>
|
||||||
|
<div style={CARD_META_LABEL}>Company</div>
|
||||||
|
{isTeam
|
||||||
|
? <Link to={`/company/${company.id}`} className="table-link" style={{ fontSize: 13 }}>{company.name}</Link>
|
||||||
|
: <div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{company.name}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<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 }}><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||||
|
<div>
|
||||||
|
<div style={CARD_META_LABEL}>Started</div>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{fmtDate(project.created_at)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginLeft: 'auto', display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 5, minWidth: 160 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
|
||||||
|
<div style={CARD_META_LABEL}>Completion</div>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 500, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>{pct}%</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ width: '100%', height: 5, borderRadius: 3, background: 'var(--border)', overflow: 'hidden' }}>
|
||||||
|
<div style={{ height: '100%', width: `${pct}%`, background: 'var(--accent)', borderRadius: 3, transition: 'width 400ms ease' }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{approved} of {tasks.length} approved</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs + content card */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
|
||||||
|
{(() => {
|
||||||
|
const tabCounts = {
|
||||||
|
tasks: rows.filter(r => !doneStatuses.has(r.status)).length,
|
||||||
|
completed: rows.filter(r => doneStatuses.has(r.status)).length,
|
||||||
|
members: members.filter(m => m.profile?.role === 'external').length,
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10 }}>
|
||||||
|
{taskTabs.map(tab => {
|
||||||
|
const count = tabCounts[tab.id] ?? 0;
|
||||||
|
const active = activeTab === tab.id;
|
||||||
|
return (
|
||||||
|
<button key={tab.id} onClick={() => { setActiveTab(tab.id); setFilterStatus('all'); setFilterRevision('all'); setFilterType('all'); setFilterAssigned('all'); }} className={`section-tab-btn${active ? ' is-active' : ''}`}>
|
||||||
|
{tab.label}
|
||||||
|
{tab.id !== 'all' && count > 0 && <span style={{ marginLeft: 5, fontSize: 12, fontWeight: 600, color: active ? 'var(--accent)' : 'var(--text-muted)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 10, padding: '3px 8px' }}>{count}</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{activeTab === 'members' && isTeam && (
|
||||||
|
<button className="btn btn-outline" style={{ marginLeft: 'auto' }} onClick={() => { const currentIds = new Set(members.filter(m => m.profile?.role === 'external').map(m => m.profile_id)); setSelectedExts(currentIds); setAddingMember(true); }}>Manage</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
|
{activeTab !== 'members' && (
|
||||||
|
<div className="scrollbar-thin-theme table-scroll-fade 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: '14%' }} />
|
||||||
|
<col style={{ width: '10%' }} />
|
||||||
|
<col style={{ width: '30%' }} />
|
||||||
|
<col style={{ width: '12%' }} />
|
||||||
|
<col style={{ width: '18%' }} />
|
||||||
|
<col style={{ width: '16%' }} />
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ ...TASK_TABLE_TH_STYLE, whiteSpace: 'nowrap' }}>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span onClick={() => toggle('status')} style={{ cursor: 'pointer', userSelect: 'none' }}>Status<span style={{ marginLeft: 4, opacity: sortKey === 'status' ? 0.85 : 0.2, fontSize: 9 }}>{sortKey === 'status' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span></span>
|
||||||
|
<FilterDropdown value={filterStatus} onChange={setFilterStatus} options={STATUS_FILTER_OPTIONS} />
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<th style={{ ...TASK_TABLE_TH_STYLE, whiteSpace: 'nowrap' }}>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span onClick={() => toggle('revision')} style={{ cursor: 'pointer', userSelect: 'none' }}>R#<span style={{ marginLeft: 4, opacity: sortKey === 'revision' ? 0.85 : 0.2, fontSize: 9 }}>{sortKey === 'revision' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span></span>
|
||||||
|
<FilterDropdown value={filterRevision} onChange={setFilterRevision} options={REVISION_FILTER_OPTIONS} />
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<SortTh col="title" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TASK_TABLE_TH_STYLE}>Project</SortTh>
|
||||||
|
<th style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center', whiteSpace: 'nowrap' }}>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span onClick={() => toggle('assigned')} style={{ cursor: 'pointer', userSelect: 'none' }}>Assigned<span style={{ marginLeft: 4, opacity: sortKey === 'assigned' ? 0.85 : 0.2, fontSize: 9 }}>{sortKey === 'assigned' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span></span>
|
||||||
|
<FilterDropdown value={filterAssigned} onChange={setFilterAssigned} options={ASSIGNED_FILTER_OPTIONS} />
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<th style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center', whiteSpace: 'nowrap' }}>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span onClick={() => toggle('serviceType')} style={{ cursor: 'pointer', userSelect: 'none' }}>Task Type<span style={{ marginLeft: 4, opacity: sortKey === 'serviceType' ? 0.85 : 0.2, fontSize: 9 }}>{sortKey === 'serviceType' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span></span>
|
||||||
|
<FilterDropdown value={filterType} onChange={setFilterType} options={TYPE_FILTER_OPTIONS} />
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Due</SortTh>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{visibleRows.length === 0 ? (
|
||||||
|
<tr><td colSpan={6} style={{ textAlign: 'center', padding: '40px 0', color: 'var(--text-muted)', fontSize: 13 }}>No tasks</td></tr>
|
||||||
|
) : visibleRows.map(row => {
|
||||||
|
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.rowKey}>
|
||||||
|
<td style={{ ...TASK_TABLE_TD_BASE }}><StatusBadge status={row.status} /></td>
|
||||||
|
<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={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)' }}>
|
||||||
|
<Link to={`/tasks/${row.id}`} className="table-link">{row.title || '—'}</Link>
|
||||||
|
</td>
|
||||||
|
<td style={{ ...TASK_TABLE_TD_BASE, textAlign: 'center' }}>
|
||||||
|
{row.assignedName && row.assignedTo
|
||||||
|
? <Link to={`/profile/${row.assignedTo}`} style={{ display: 'inline-flex' }}>
|
||||||
|
<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={{ ...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>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'members' && isTeam && (
|
||||||
|
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
{(() => {
|
||||||
|
const subs = members.filter(m => m.profile?.role === 'external');
|
||||||
|
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;
|
||||||
|
const bv = subProjectCounts[b.profile_id] || 0;
|
||||||
|
return subSortDir === 'asc' ? av - bv : bv - av;
|
||||||
|
}
|
||||||
|
const av = subSortKey === 'name' ? (a.profile?.name || '') : (a.profile?.email || '');
|
||||||
|
const bv = subSortKey === 'name' ? (b.profile?.name || '') : (b.profile?.email || '');
|
||||||
|
return subSortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<table className="table-sticky-head" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
|
||||||
|
<colgroup>
|
||||||
|
<col style={{ width: '5%' }} />
|
||||||
|
<col style={{ width: '30%' }} />
|
||||||
|
<col style={{ width: '40%' }} />
|
||||||
|
<col style={{ width: '15%' }} />
|
||||||
|
<col style={{ width: '10%' }} />
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<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 projCount = subProjectCounts[m.profile_id] || 0;
|
||||||
|
return (
|
||||||
|
<tr key={m.id}>
|
||||||
|
<td style={{ ...TASK_TABLE_TD_BASE }}>
|
||||||
|
<ProfileAvatar profile={m.profile} name={m.profile?.name} size={26} fontSize={10} />
|
||||||
|
</td>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right 30% */}
|
||||||
|
<div style={{ flex: '0 0 calc(30% - 24px)', minWidth: 0, display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||||
|
<div className="card" style={CARD}>
|
||||||
|
<div style={LABEL}>Main Contact</div>
|
||||||
|
{companyClients.length === 0
|
||||||
|
? <div className="card-empty-center">No contacts</div>
|
||||||
|
: (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{companyClients.map(person => {
|
||||||
|
return (
|
||||||
|
<div key={person.id} style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<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>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<div style={LABEL}>Activity</div>
|
||||||
|
{(() => {
|
||||||
|
const SHOW = { task_started: { label: 'started', color: 'var(--info)', bg: 'color-mix(in srgb, var(--info) 15%, transparent)', path: 'M6 4l14 8-14 8V4z' }, task_on_hold: { label: 'placed on hold', color: 'var(--danger)', bg: 'color-mix(in srgb, var(--danger) 15%, transparent)', path: 'M6 4h4v16H6zM14 4h4v16h-4z' }, task_approved: { label: 'approved', color: 'var(--positive)', bg: 'color-mix(in srgb, var(--positive) 15%, transparent)', path: 'M4 13l5 5L20 7' } };
|
||||||
|
const filtered = activityLog;
|
||||||
|
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 => {
|
||||||
|
const cfg = SHOW[e.action];
|
||||||
|
const d = new Date(e.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 });
|
||||||
|
return (
|
||||||
|
<div key={e.id} style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
|
||||||
|
<div style={{ width: 27, height: 27, borderRadius: '50%', background: cfg.bg, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, marginTop: 1 }}>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={cfg.color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d={cfg.path} /></svg>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 13, lineHeight: 1.4 }}>
|
||||||
|
<span style={{ color: 'var(--text-primary)', fontWeight: 500 }}>{e.actor_name || 'Fourge'}</span>
|
||||||
|
<span style={{ color: 'var(--text-muted)' }}> {cfg.label} </span>
|
||||||
|
{e.task_title && <span style={{ color: 'var(--text-primary)' }}>{e.task_title}</span>}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>{dateStr} · {timeStr}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showClientTask && isClient && (
|
||||||
|
<div style={popupOverlayStyle}>
|
||||||
|
<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] : []}
|
||||||
|
initialCompanyId={company?.id || ''}
|
||||||
|
initialValues={{ companyId: company?.id || '', project: project.name }}
|
||||||
|
currentUser={currentUser}
|
||||||
|
showRequester={false}
|
||||||
|
lockedFields={['company', 'project']}
|
||||||
|
onSubmit={handleClientTask}
|
||||||
|
onCancel={() => { setShowClientTask(false); setClientTaskKey(k => k + 1); setClientTaskError(''); }}
|
||||||
|
saving={clientTaskSaving}
|
||||||
|
error={clientTaskError}
|
||||||
|
submitLabel="Save"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{addingMember && isTeam && (
|
||||||
|
<div style={popupOverlayStyle}>
|
||||||
|
<div style={{ ...TASK_MODAL_SHELL_STYLE, width: 'min(480px, 96vw)', display: 'flex', flexDirection: 'column', gap: 16, padding: 0 }} onClick={e => e.stopPropagation()}>
|
||||||
|
<div style={{ 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 className="card-empty-center">No subcontractors</div>
|
||||||
|
: <table className="table-sticky-head" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
|
||||||
|
<colgroup>
|
||||||
|
<col style={{ width: '5%' }} />
|
||||||
|
<col style={{ width: '36%' }} />
|
||||||
|
<col style={{ width: '49%' }} />
|
||||||
|
<col style={{ width: '10%' }} />
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<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>
|
||||||
|
{[...externalProfiles].sort((a, b) => {
|
||||||
|
const av = subSortKey === 'name' ? (a.name || '') : (a.email || '');
|
||||||
|
const bv = subSortKey === 'name' ? (b.name || '') : (b.email || '');
|
||||||
|
return subSortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
|
||||||
|
}).map(p => {
|
||||||
|
const checked = selectedExts.has(p.id);
|
||||||
|
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' }}>
|
||||||
|
<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>
|
||||||
|
<td style={{ padding: '5px 0', border: 'none', background: 'transparent', textAlign: 'center' }}>
|
||||||
|
<div style={{ width: 18, height: 18, borderRadius: 4, border: `2px solid ${checked ? 'var(--accent)' : 'var(--border)'}`, background: checked ? 'var(--accent)' : 'transparent', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', transition: 'all 140ms' }}>
|
||||||
|
{checked && <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="var(--text-on-accent)" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><polyline points="4,13 9,18 20,7"/></svg>}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||